archstrict 0.0.0 → 0.2.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/.agents/hooks/hooks.json +29 -0
- package/.agents/hooks/post-tool-use.mjs +107 -0
- package/.agents/hooks/pre-tool-use.mjs +182 -0
- package/.agents/mcp/server.mjs +71 -0
- package/.agents/plugin.json +19 -0
- package/AGENTS.md +81 -0
- package/CHANGELOG.md +77 -0
- package/README.ja.md +142 -0
- package/README.md +143 -2
- package/dist/augmentation-cache.js +65 -0
- package/dist/check-options.js +40 -0
- package/dist/classify.js +148 -0
- package/dist/cli.js +243 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +194 -0
- package/dist/edge-cache.js +530 -0
- package/dist/gitignore.js +271 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +125 -0
- package/dist/module-graph.js +2179 -0
- package/dist/project-path.js +59 -0
- package/dist/report-error.js +13 -0
- package/dist/rules/config-meaning.js +143 -0
- package/dist/rules/constraints.js +419 -0
- package/dist/rules/cycles.js +285 -0
- package/dist/rules/deprecated.js +67 -0
- package/dist/rules/empty-rule.js +101 -0
- package/dist/rules/moves.js +79 -0
- package/dist/rules/must-be-empty.js +52 -0
- package/dist/rules/public-surface.js +100 -0
- package/dist/rules/uncovered.js +75 -0
- package/dist/todo-migration.js +112 -0
- package/dist/todo-store.js +434 -0
- package/dist/type-closure.js +959 -0
- package/dist/type-leak.js +590 -0
- package/dist/verbs/agents.js +116 -0
- package/dist/verbs/check.js +1011 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +538 -0
- package/dist/verbs/map-shape.js +78 -0
- package/dist/verbs/recommend.js +863 -0
- package/dist/verbs/rules.js +188 -0
- package/dist/verbs/search.js +109 -0
- package/dist/verbs/simulate.js +220 -0
- package/dist/verbs/todo.js +180 -0
- package/dist/warm-graph.js +82 -0
- package/docs/boundary-patterns.md +374 -0
- package/docs/calibrated-rules-design.md +124 -0
- package/docs/init-singleton-modules.md +133 -0
- package/docs/maintenance.md +109 -0
- package/docs/releasing.md +58 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +25 -0
- package/package.json +61 -4
- package/skills/archstrict/SKILL.md +54 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +116 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +915 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +66 -0
- package/skills/archstrict/references/recommend.md +98 -0
- package/skills/archstrict/references/rules.md +149 -0
- package/skills/archstrict/references/simulate.md +109 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Responsibility: parse argv and dispatch to a verb (init, check, todo, rules, agents, recommend, fix, simulate, search, hotspots).
|
|
3
|
+
// Boundary: no rule logic here; verbs live in their own modules.
|
|
4
|
+
import { startArchstrictMcpServer } from "./mcp-server.js";
|
|
5
|
+
import { search, formatSearchText } from "./verbs/search.js";
|
|
6
|
+
import { simulate, formatSimulateText } from "./verbs/simulate.js";
|
|
7
|
+
import { agents, formatAgentsText } from "./verbs/agents.js";
|
|
8
|
+
import { recommend, formatRecommendText } from "./verbs/recommend.js";
|
|
9
|
+
import { fix, formatFixText } from "./verbs/fix.js";
|
|
10
|
+
import { init } from "./verbs/init.js";
|
|
11
|
+
import { check, formatText, hasBlockingViolations } from "./verbs/check.js";
|
|
12
|
+
import { todo } from "./verbs/todo.js";
|
|
13
|
+
import { rules, formatRulesText } from "./verbs/rules.js";
|
|
14
|
+
import { hotspots, formatHotspotsText } from "./verbs/hotspots.js";
|
|
15
|
+
import { ReportError } from "./report-error.js";
|
|
16
|
+
import { parseCheckArgv } from "./check-options.js";
|
|
17
|
+
// Text and JSON share one shape: the message, then the one command to run.
|
|
18
|
+
// A violation already carries `do`; a thrown config or missing-file
|
|
19
|
+
// failure goes through here so it does too.
|
|
20
|
+
function reportFailure(error, verb, asJson) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
const doText = error instanceof ReportError ? error.do : `archstrict ${verb}`;
|
|
23
|
+
if (asJson) {
|
|
24
|
+
process.stdout.write(`${JSON.stringify({ error: message, do: doText })}\n`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
process.stderr.write(`archstrict: ${message}\ndo: ${doText}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// `--json` is a flag, never read as a directory, so it's stripped before
|
|
31
|
+
// the positional rules apply. Every other argument is a directory, so any
|
|
32
|
+
// other flag-looking argument ("-" prefix) is rejected outright rather
|
|
33
|
+
// than silently accepted as a directory name. More than one positional
|
|
34
|
+
// means the shell expanded an unquoted glob (`src/*` with more than one
|
|
35
|
+
// match) - archstrict cannot tell that apart from a person genuinely
|
|
36
|
+
// typing two directory names, so both read the same way: init takes
|
|
37
|
+
// exactly one.
|
|
38
|
+
function parseInitArgv(argv) {
|
|
39
|
+
const positional = argv.filter((arg) => arg !== "--json");
|
|
40
|
+
for (const arg of positional) {
|
|
41
|
+
if (arg.startsWith("-"))
|
|
42
|
+
throw new ReportError(`unknown option '${arg}'`, "archstrict init");
|
|
43
|
+
}
|
|
44
|
+
if (positional.length > 1) {
|
|
45
|
+
throw new ReportError(`init takes one directory; got ${positional.length} arguments (the shell expands an unquoted * or src/*)`, "archstrict init");
|
|
46
|
+
}
|
|
47
|
+
return positional[0];
|
|
48
|
+
}
|
|
49
|
+
async function runInit(args) {
|
|
50
|
+
const asJson = args.includes("--json");
|
|
51
|
+
const result = await init(process.cwd(), parseInitArgv(args));
|
|
52
|
+
if (asJson) {
|
|
53
|
+
// Field names and shapes follow check's and todo's own --json
|
|
54
|
+
// convention: one object, keys in the same order this prints them.
|
|
55
|
+
// `typesPath` (not `generatedPath`, InitResult's own internal name)
|
|
56
|
+
// matches archstrict.types.ts's own file name, which is what an agent
|
|
57
|
+
// reading this JSON actually needs to find.
|
|
58
|
+
process.stdout.write(`${JSON.stringify({
|
|
59
|
+
configPath: result.configPath,
|
|
60
|
+
typesPath: result.generatedPath,
|
|
61
|
+
configWritten: result.configWritten,
|
|
62
|
+
opened: result.opened === "" ? null : result.opened,
|
|
63
|
+
moduleNames: result.moduleNames,
|
|
64
|
+
hiddenDirs: result.hiddenDirs,
|
|
65
|
+
noiseDirs: result.noiseDirs,
|
|
66
|
+
testFileExcludes: result.testFileExcludes.map((t) => ({ pattern: t.label, exclude: t.exclude, files: t.fileCount })),
|
|
67
|
+
uncovered: result.uncovered.map((g) => ({
|
|
68
|
+
path: g.rel,
|
|
69
|
+
kind: g.kind,
|
|
70
|
+
files: g.fileCount,
|
|
71
|
+
declare: g.entry,
|
|
72
|
+
exclude: g.excludeGlob,
|
|
73
|
+
})),
|
|
74
|
+
notes: result.notes,
|
|
75
|
+
do: result.doText,
|
|
76
|
+
})}\n`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
for (const line of result.messageLines)
|
|
80
|
+
process.stdout.write(`${line}\n`);
|
|
81
|
+
process.stdout.write(`do: ${result.doText}\n`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
async function runCheck(args) {
|
|
85
|
+
const parsed = parseCheckArgv(args);
|
|
86
|
+
const result = await check(process.cwd(), parsed.focusFile, {
|
|
87
|
+
prove: parsed.prove,
|
|
88
|
+
rules: parsed.rules,
|
|
89
|
+
modules: parsed.modules,
|
|
90
|
+
frozen: parsed.frozen,
|
|
91
|
+
});
|
|
92
|
+
if (parsed.asJson) {
|
|
93
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
process.stdout.write(formatText(result));
|
|
97
|
+
}
|
|
98
|
+
return hasBlockingViolations(result) ? 1 : 0;
|
|
99
|
+
}
|
|
100
|
+
async function runTodo(args) {
|
|
101
|
+
const asJson = args.includes("--json");
|
|
102
|
+
const result = await todo(process.cwd());
|
|
103
|
+
if (asJson) {
|
|
104
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (result.firstRun) {
|
|
108
|
+
process.stdout.write(`froze ${result.added} violation(s)\n`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
process.stdout.write(`pruned ${result.pruned} stale entrie(s)\n`);
|
|
112
|
+
}
|
|
113
|
+
for (const note of result.notes ?? [])
|
|
114
|
+
process.stdout.write(`note: ${note}\n`);
|
|
115
|
+
process.stdout.write(result.notes !== undefined && result.notes.length > 0
|
|
116
|
+
? `do: split the module named above before treating this freeze as done\n`
|
|
117
|
+
: `do: archstrict check\n`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
async function runRules(args) {
|
|
121
|
+
const asJson = args.includes("--json");
|
|
122
|
+
const paths = args.filter((arg) => arg !== "--json");
|
|
123
|
+
if (paths.length !== 1)
|
|
124
|
+
throw new Error("usage: archstrict rules <path> [--json]");
|
|
125
|
+
const result = await rules(process.cwd(), paths[0]);
|
|
126
|
+
process.stdout.write(asJson ? JSON.stringify(result, null, 2) + "\n" : formatRulesText(result));
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
function runAgents(args) {
|
|
130
|
+
if (args.some((arg) => arg !== "--json" && arg !== "--remove")) {
|
|
131
|
+
throw new Error("usage: archstrict agents [--remove] [--json]");
|
|
132
|
+
}
|
|
133
|
+
const result = agents(process.cwd(), args.includes("--remove"));
|
|
134
|
+
process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatAgentsText(result));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
async function runRecommend(args) {
|
|
138
|
+
const paths = args.filter(arg => arg !== "--json");
|
|
139
|
+
if (paths.length > 1 || paths.some(arg => arg.startsWith("-"))) {
|
|
140
|
+
throw new Error("usage: archstrict recommend [dir] [--json]");
|
|
141
|
+
}
|
|
142
|
+
// Without a config, the directory controls init's own in-memory walk; a
|
|
143
|
+
// declared config supplies its own scope regardless.
|
|
144
|
+
const result = await recommend(process.cwd(), paths[0]);
|
|
145
|
+
process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatRecommendText(result));
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
async function runFix(args) {
|
|
149
|
+
const paths = args.filter(arg => arg !== "--json" && arg !== "--dry-run");
|
|
150
|
+
if (paths.length > 1 || paths.some(arg => arg.startsWith("-"))) {
|
|
151
|
+
throw new Error("usage: archstrict fix [file] [--dry-run] [--json]");
|
|
152
|
+
}
|
|
153
|
+
const dryRun = args.includes("--dry-run");
|
|
154
|
+
const result = await fix(process.cwd(), paths[0], dryRun);
|
|
155
|
+
process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatFixText(result));
|
|
156
|
+
return dryRun || (result.unfixable.length === 0 && result.reverted.length === 0) ? 0 : 1;
|
|
157
|
+
}
|
|
158
|
+
async function runSimulate(args) {
|
|
159
|
+
if (args.some(arg => arg !== "--json" && arg !== "--whole-project")) {
|
|
160
|
+
throw new Error("usage: archstrict simulate [--json] [--whole-project]");
|
|
161
|
+
}
|
|
162
|
+
let input = "";
|
|
163
|
+
process.stdin.setEncoding("utf8");
|
|
164
|
+
for await (const chunk of process.stdin)
|
|
165
|
+
input += chunk;
|
|
166
|
+
const body = JSON.parse(input);
|
|
167
|
+
if (typeof body !== "object" || body === null || !("changes" in body) || !Array.isArray(body.changes)) {
|
|
168
|
+
throw new Error("stdin must contain a JSON object with a changes array");
|
|
169
|
+
}
|
|
170
|
+
const result = await simulate(process.cwd(), body.changes, { wholeProject: args.includes("--whole-project") });
|
|
171
|
+
process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatSimulateText(result));
|
|
172
|
+
return result.added.length === 0 ? 0 : 1;
|
|
173
|
+
}
|
|
174
|
+
async function runSearch(args) {
|
|
175
|
+
const query = args.filter(arg => arg !== "--json").join(" ");
|
|
176
|
+
const result = await search(process.cwd(), query);
|
|
177
|
+
process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatSearchText(result));
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
async function runHotspots(args) {
|
|
181
|
+
let since;
|
|
182
|
+
for (let i = 0; i < args.length; i++) {
|
|
183
|
+
const arg = args[i];
|
|
184
|
+
if (arg === "--json")
|
|
185
|
+
continue;
|
|
186
|
+
if (arg === "--since" && since === undefined && args[i + 1] !== undefined && !args[i + 1].startsWith("--")) {
|
|
187
|
+
since = args[++i];
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
throw new Error("usage: archstrict hotspots [--since <git ref or date>] [--json]");
|
|
191
|
+
}
|
|
192
|
+
const result = await hotspots(process.cwd(), since);
|
|
193
|
+
process.stdout.write(args.includes("--json") ? JSON.stringify(result, null, 2) + "\n" : formatHotspotsText(result));
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
// The connected transport's stdin listener keeps Node alive after this function returns.
|
|
197
|
+
// A real subprocess stayed alive with stdin open and exited when stdin closed;
|
|
198
|
+
// the host can also terminate it. A separate server-closed promise is unnecessary.
|
|
199
|
+
async function runMcp() {
|
|
200
|
+
await startArchstrictMcpServer(process.cwd());
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
async function main(argv) {
|
|
204
|
+
const [verb, ...rest] = argv;
|
|
205
|
+
if (verb === undefined) {
|
|
206
|
+
process.stderr.write("usage: archstrict <init|check|todo|rules|agents|recommend|fix|simulate|search|hotspots|mcp> [args]\n");
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
if (verb === "mcp")
|
|
211
|
+
return await runMcp();
|
|
212
|
+
if (verb === "search")
|
|
213
|
+
return await runSearch(rest);
|
|
214
|
+
if (verb === "hotspots")
|
|
215
|
+
return await runHotspots(rest);
|
|
216
|
+
if (verb === "simulate")
|
|
217
|
+
return await runSimulate(rest);
|
|
218
|
+
if (verb === "fix")
|
|
219
|
+
return await runFix(rest);
|
|
220
|
+
if (verb === "recommend")
|
|
221
|
+
return await runRecommend(rest);
|
|
222
|
+
if (verb === "init")
|
|
223
|
+
return await runInit(rest);
|
|
224
|
+
if (verb === "check")
|
|
225
|
+
return await runCheck(rest);
|
|
226
|
+
if (verb === "todo")
|
|
227
|
+
return await runTodo(rest);
|
|
228
|
+
if (verb === "rules")
|
|
229
|
+
return await runRules(rest);
|
|
230
|
+
if (verb === "agents")
|
|
231
|
+
return runAgents(rest);
|
|
232
|
+
process.stderr.write(`archstrict: '${verb}' is not implemented yet\ndo: archstrict init\n`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// A config or missing-file error throws before any real output.
|
|
237
|
+
// reportFailure prints the message and a do: line — the same pair
|
|
238
|
+
// a violation carries — instead of a bare message or stack trace.
|
|
239
|
+
reportFailure(error, verb, rest.includes("--json"));
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Responsibility: attach structured config locations to raw violations and
|
|
2
|
+
// resolve each config value path against the config's TypeScript source.
|
|
3
|
+
// Boundary: rule evaluation and report formatting stay in their own modules.
|
|
4
|
+
// Rules can supply an exact index when duplicate config entries need distinct
|
|
5
|
+
// locations; this module owns the fallback selection for other findings.
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import ts from "typescript";
|
|
8
|
+
export const CONFIG_POINTER_SPECS = Symbol("archstrict.config-pointer-specs");
|
|
9
|
+
function pathParts(pointer) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
const pattern = /(?:^|\.)([^.\[\]]+)|\[(\d+)\]/g;
|
|
12
|
+
for (const match of pointer.matchAll(pattern)) {
|
|
13
|
+
parts.push(match[2] === undefined ? match[1] : Number(match[2]));
|
|
14
|
+
}
|
|
15
|
+
return parts;
|
|
16
|
+
}
|
|
17
|
+
function valueAt(config, pointer) {
|
|
18
|
+
let value = config;
|
|
19
|
+
for (const part of pathParts(pointer)) {
|
|
20
|
+
if (typeof part === "number") {
|
|
21
|
+
value = Array.isArray(value) ? value[part] : undefined;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
value = typeof value === "object" && value !== null ? value[part] : undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value) && value.length > 20) {
|
|
28
|
+
return [...value.slice(0, 20), `... ${value.length - 20} more`];
|
|
29
|
+
}
|
|
30
|
+
return value ?? null;
|
|
31
|
+
}
|
|
32
|
+
export function withPointerSpecs(violation, specs) {
|
|
33
|
+
Object.defineProperty(violation, CONFIG_POINTER_SPECS, { value: specs });
|
|
34
|
+
return violation;
|
|
35
|
+
}
|
|
36
|
+
function unwrap(expression) {
|
|
37
|
+
let current = expression;
|
|
38
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) {
|
|
39
|
+
current = current.expression;
|
|
40
|
+
}
|
|
41
|
+
return current;
|
|
42
|
+
}
|
|
43
|
+
function propertyName(name) {
|
|
44
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name))
|
|
45
|
+
return name.text;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function isLiteralPosition(node) {
|
|
49
|
+
return ts.isObjectLiteralExpression(node) || ts.isArrayLiteralExpression(node) || ts.isStringLiteralLike(node) ||
|
|
50
|
+
ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword ||
|
|
51
|
+
node.kind === ts.SyntaxKind.NullKeyword;
|
|
52
|
+
}
|
|
53
|
+
function configObject(sourceFile) {
|
|
54
|
+
for (const statement of sourceFile.statements) {
|
|
55
|
+
if (ts.isExportAssignment(statement))
|
|
56
|
+
return unwrap(statement.expression);
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function syntaxAt(root, parts) {
|
|
61
|
+
let current = root;
|
|
62
|
+
let nearestLiteral = root;
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
const expression = ts.isExpression(current) ? unwrap(current) : current;
|
|
65
|
+
let next;
|
|
66
|
+
if (typeof part === "number" && ts.isArrayLiteralExpression(expression)) {
|
|
67
|
+
// A spread contributes an unknown number of runtime entries. Once one
|
|
68
|
+
// appears before this index, syntax indices no longer identify values.
|
|
69
|
+
if (expression.elements.slice(0, part + 1).some(ts.isSpreadElement))
|
|
70
|
+
break;
|
|
71
|
+
next = expression.elements[part];
|
|
72
|
+
}
|
|
73
|
+
else if (typeof part === "string" && ts.isObjectLiteralExpression(expression)) {
|
|
74
|
+
let selected;
|
|
75
|
+
let selectedIndex = -1;
|
|
76
|
+
let lastUnknownIndex = -1;
|
|
77
|
+
expression.properties.forEach((candidate, index) => {
|
|
78
|
+
if (ts.isSpreadAssignment(candidate)) {
|
|
79
|
+
lastUnknownIndex = index;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!ts.isPropertyAssignment(candidate) && !ts.isShorthandPropertyAssignment(candidate))
|
|
83
|
+
return;
|
|
84
|
+
const name = propertyName(candidate.name);
|
|
85
|
+
if (name === undefined)
|
|
86
|
+
lastUnknownIndex = index;
|
|
87
|
+
else if (name === part) {
|
|
88
|
+
selected = candidate;
|
|
89
|
+
selectedIndex = index;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// A later spread or computed key can replace the selected runtime
|
|
93
|
+
// value. The containing object is the nearest reliable literal then.
|
|
94
|
+
if (selected === undefined || selectedIndex < lastUnknownIndex)
|
|
95
|
+
break;
|
|
96
|
+
next = ts.isPropertyAssignment(selected) ? selected.initializer : selected.name;
|
|
97
|
+
}
|
|
98
|
+
if (next === undefined)
|
|
99
|
+
break;
|
|
100
|
+
current = next;
|
|
101
|
+
const unwrapped = ts.isExpression(next) ? unwrap(next) : next;
|
|
102
|
+
if (isLiteralPosition(unwrapped))
|
|
103
|
+
nearestLiteral = unwrapped;
|
|
104
|
+
}
|
|
105
|
+
const selected = ts.isExpression(current) ? unwrap(current) : current;
|
|
106
|
+
return isLiteralPosition(selected) ? selected : nearestLiteral;
|
|
107
|
+
}
|
|
108
|
+
export function createConfigLocator(config, sourceOverride) {
|
|
109
|
+
let sourceFile;
|
|
110
|
+
let root;
|
|
111
|
+
if (sourceOverride !== undefined || existsSync(config.configPath)) {
|
|
112
|
+
const source = sourceOverride ?? readFileSync(config.configPath, "utf8");
|
|
113
|
+
sourceFile = ts.createSourceFile(config.configPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
114
|
+
root = configObject(sourceFile);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
pointer(pointer, role) {
|
|
118
|
+
let line = 1;
|
|
119
|
+
let column = 1;
|
|
120
|
+
if (sourceFile !== undefined && root !== undefined) {
|
|
121
|
+
const node = syntaxAt(root, pathParts(pointer));
|
|
122
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
123
|
+
line = position.line + 1;
|
|
124
|
+
column = position.character + 1;
|
|
125
|
+
}
|
|
126
|
+
return { path: config.configPath, pointer, value: valueAt(config, pointer), line, column, role };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function declaredModulePointerForName(config, name) {
|
|
131
|
+
const index = config.declaredModules?.findIndex((entry) => entry.name === name) ?? -1;
|
|
132
|
+
return index < 0 ? "declaredModules" : `declaredModules[${index}]`;
|
|
133
|
+
}
|
|
134
|
+
function edgeIdentifier(kind, entry) {
|
|
135
|
+
if (kind === "allowDeny")
|
|
136
|
+
return `${entry.source} -> ${entry.targetNamespace}`;
|
|
137
|
+
if (kind === "order")
|
|
138
|
+
return `${entry.tagNamespace}${entry.within === undefined ? "" : ` within ${entry.within}`}`;
|
|
139
|
+
const from = typeof entry.from === "string" ? entry.from : JSON.stringify(entry.from);
|
|
140
|
+
const to = typeof entry.to === "string" ? entry.to : JSON.stringify(entry.to);
|
|
141
|
+
return `${from} -> ${to}`;
|
|
142
|
+
}
|
|
143
|
+
function indexByEvidence(entries, predicate) {
|
|
144
|
+
return entries?.findIndex(predicate) ?? -1;
|
|
145
|
+
}
|
|
146
|
+
function pointerSpecs(violation, config) {
|
|
147
|
+
if (violation[CONFIG_POINTER_SPECS] !== undefined)
|
|
148
|
+
return [...violation[CONFIG_POINTER_SPECS]];
|
|
149
|
+
switch (violation.rule) {
|
|
150
|
+
case "public-surface-bypass":
|
|
151
|
+
case "type-leak":
|
|
152
|
+
return [{ pointer: declaredModulePointerForName(config, violation.todoModule), role: "governs" }];
|
|
153
|
+
case "uncovered-module":
|
|
154
|
+
return [{ pointer: "declaredModules", role: "governs" }];
|
|
155
|
+
case "cycle":
|
|
156
|
+
return [
|
|
157
|
+
{ pointer: declaredModulePointerForName(config, violation.todoModule), role: "governs" },
|
|
158
|
+
{ pointer: "ignoredCycles", role: "edit-here" },
|
|
159
|
+
];
|
|
160
|
+
case "stale-cycle-exception": {
|
|
161
|
+
const index = indexByEvidence(config.ignoredCycles, ([a, b]) => violation.evidence.includes(`['${a}', '${b}']`));
|
|
162
|
+
return [{ pointer: index < 0 ? "ignoredCycles" : `ignoredCycles[${index}]`, role: "fired" }];
|
|
163
|
+
}
|
|
164
|
+
case "must-be-empty": {
|
|
165
|
+
const index = indexByEvidence(config.mustBeEmpty, (entry) => violation.evidence.includes(`'${entry.glob}'`));
|
|
166
|
+
return [{ pointer: index < 0 ? "mustBeEmpty" : `mustBeEmpty[${index}]`, role: "fired" }];
|
|
167
|
+
}
|
|
168
|
+
case "deprecated-edge-increased": {
|
|
169
|
+
const index = indexByEvidence(config.deprecated, (entry) => violation.evidence.startsWith(`${entry.from} -> ${entry.to}:`));
|
|
170
|
+
return [{ pointer: index < 0 ? "deprecated" : `deprecated[${index}].count`, role: "fired" }];
|
|
171
|
+
}
|
|
172
|
+
case "tag-boundary": {
|
|
173
|
+
const rules = config.edges?.allowDeny ?? [];
|
|
174
|
+
const index = rules.findIndex((entry) => violation.because === entry.because && violation.evidence.includes(`from '${entry.source}'`));
|
|
175
|
+
if (index < 0)
|
|
176
|
+
return [{ pointer: "edges.allowDeny", role: "fired" }];
|
|
177
|
+
const rule = rules[index];
|
|
178
|
+
if (rule.deny !== undefined) {
|
|
179
|
+
const deniedIndex = rule.deny.findIndex((value) => violation.evidence.includes(`'${rule.targetNamespace}:${value}'`));
|
|
180
|
+
if (deniedIndex >= 0)
|
|
181
|
+
return [
|
|
182
|
+
{ pointer: `edges.allowDeny[${index}].deny[${deniedIndex}]`, role: "fired" },
|
|
183
|
+
{ pointer: `edges.allowDeny[${index}].allow`, role: "edit-here" },
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
return [{ pointer: `edges.allowDeny[${index}].allow`, role: "fired" }];
|
|
187
|
+
}
|
|
188
|
+
case "tag-order": {
|
|
189
|
+
const rules = config.edges?.order ?? [];
|
|
190
|
+
const index = rules.findIndex((entry) => violation.because === entry.because && violation.evidence.includes(`(${entry.tagNamespace} sequence:`));
|
|
191
|
+
return [{ pointer: index < 0 ? "edges.order" : `edges.order[${index}].sequence`, role: "fired" }];
|
|
192
|
+
}
|
|
193
|
+
case "point-rule": {
|
|
194
|
+
const rules = config.edges?.point ?? [];
|
|
195
|
+
const index = rules.findIndex((entry) => violation.because === entry.because &&
|
|
196
|
+
violation.do.includes(`'${edgeIdentifier("point", entry)}'`));
|
|
197
|
+
return [{ pointer: index < 0 ? "edges.point" : `edges.point[${index}]`, role: "fired" }];
|
|
198
|
+
}
|
|
199
|
+
case "clean-module-has-todo": {
|
|
200
|
+
const index = config.strict?.findIndex((name) => violation.evidence.includes(`module '${name}'`)) ?? -1;
|
|
201
|
+
return [{ pointer: index < 0 ? "strict" : `strict[${index}]`, role: "fired" }];
|
|
202
|
+
}
|
|
203
|
+
case "stale-todo":
|
|
204
|
+
return [{ pointer: "declaredModules", role: "governs" }];
|
|
205
|
+
case "empty-rule-set": {
|
|
206
|
+
if (violation.evidence === "no modules declared in declaredModules") {
|
|
207
|
+
return [{ pointer: "declaredModules", role: "fired" }];
|
|
208
|
+
}
|
|
209
|
+
const classifyIndex = indexByEvidence(config.classify, (entry) => violation.evidence.includes(`classify glob '${entry.glob}'`));
|
|
210
|
+
if (classifyIndex >= 0)
|
|
211
|
+
return [{ pointer: `classify[${classifyIndex}]`, role: "fired" }];
|
|
212
|
+
const deprecatedIndex = indexByEvidence(config.deprecated, (entry) => violation.evidence.includes(`'${entry.from} -> ${entry.to}'`));
|
|
213
|
+
if (deprecatedIndex >= 0)
|
|
214
|
+
return [{ pointer: `deprecated[${deprecatedIndex}]`, role: "fired" }];
|
|
215
|
+
for (const kind of ["allowDeny", "order", "point"]) {
|
|
216
|
+
const rules = config.edges?.[kind] ?? [];
|
|
217
|
+
const index = rules.findIndex((entry) => violation.evidence.includes(`${kind} rule '${edgeIdentifier(kind, entry)}'`));
|
|
218
|
+
if (index >= 0)
|
|
219
|
+
return [{ pointer: `edges.${kind}[${index}]`, role: "fired" }];
|
|
220
|
+
}
|
|
221
|
+
return [{ pointer: "edges", role: "fired" }];
|
|
222
|
+
}
|
|
223
|
+
case "exhaustive-allow-list": {
|
|
224
|
+
const rules = config.edges?.allowDeny ?? [];
|
|
225
|
+
const index = rules.findIndex((entry) => violation.because === entry.because &&
|
|
226
|
+
violation.evidence.includes(`'${edgeIdentifier("allowDeny", entry)}'`));
|
|
227
|
+
return [{ pointer: index < 0 ? "edges.allowDeny" : `edges.allowDeny[${index}].allow`, role: "fired" }];
|
|
228
|
+
}
|
|
229
|
+
case "config-meaning": {
|
|
230
|
+
for (const kind of ["allowDeny", "order", "point"]) {
|
|
231
|
+
const rules = config.edges?.[kind] ?? [];
|
|
232
|
+
const index = rules.findIndex((entry) => violation.evidence.includes(`${kind} rule (`) && violation.because === entry.because);
|
|
233
|
+
if (index >= 0)
|
|
234
|
+
return [{ pointer: `edges.${kind}[${index}]`, role: "fired" }];
|
|
235
|
+
}
|
|
236
|
+
return [{ pointer: "edges", role: "governs" }];
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
return [{ pointer: "declaredModules", role: "governs" }];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export function locateViolation(violation, config, locator, specs = pointerSpecs(violation, config)) {
|
|
243
|
+
const pointers = specs.map(({ pointer, role }) => locator.pointer(pointer, role));
|
|
244
|
+
const configPointers = pointers.length === 1 ? pointers[0] : pointers;
|
|
245
|
+
const primary = pointers[0];
|
|
246
|
+
const position = violation.path === config.configPath ? { line: primary.line, column: primary.column } : {};
|
|
247
|
+
return { ...violation, ...position, config: configPointers };
|
|
248
|
+
}
|
|
249
|
+
export function locateViolations(violations, config, locator) {
|
|
250
|
+
return violations.map((violation) => locateViolation(violation, config, locator));
|
|
251
|
+
}
|