archstrict 0.0.0 → 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.
- 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 +69 -0
- package/CHANGELOG.md +38 -0
- package/README.ja.md +62 -0
- package/README.md +63 -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 +239 -0
- package/dist/config-pointer.js +251 -0
- package/dist/config.js +186 -0
- package/dist/edge-cache.js +530 -0
- package/dist/mcp-server.js +111 -0
- package/dist/module-candidates.js +118 -0
- package/dist/module-graph.js +2072 -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 +417 -0
- package/dist/rules/cycles.js +257 -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/type-leak.js +562 -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/verbs/agents.js +116 -0
- package/dist/verbs/check.js +957 -0
- package/dist/verbs/fix.js +170 -0
- package/dist/verbs/hotspots.js +261 -0
- package/dist/verbs/init.js +522 -0
- package/dist/verbs/recommend.js +800 -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 +163 -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 +128 -0
- package/docs/maintenance.md +82 -0
- package/docs/releasing.md +55 -0
- package/docs/rules-edge-cache.md +50 -0
- package/docs/todo-single-file-migration.md +58 -0
- package/llms.txt +19 -0
- package/package.json +57 -4
- package/skills/archstrict/SKILL.md +42 -0
- package/skills/archstrict/references/agents-verb.md +39 -0
- package/skills/archstrict/references/config.md +107 -0
- package/skills/archstrict/references/hook.md +57 -0
- package/skills/archstrict/references/path-rules.md +57 -0
- package/skills/archstrict/references/patterns.md +883 -0
- package/skills/archstrict/references/prove-rules.md +58 -0
- package/skills/archstrict/references/rearchitect.md +35 -0
- package/skills/archstrict/references/recommend.md +80 -0
- package/skills/archstrict/references/rules.md +146 -0
- package/skills/archstrict/references/simulate.md +109 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Responsibility: convert between absolute file paths and the normalized,
|
|
2
|
+
// project-relative POSIX paths used by configuration and persistent caches.
|
|
3
|
+
// Boundary: callers own path validation and filesystem access.
|
|
4
|
+
import { relative, sep } from "node:path";
|
|
5
|
+
// A graph asks for the same file through membership and rule checks. Keep
|
|
6
|
+
// one spelling so those checks do not repeat native path parsing.
|
|
7
|
+
export function makeProjectRelativePosix(projectRoot) {
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
const rootPrefix = projectRoot.endsWith(sep) ? projectRoot : `${projectRoot}${sep}`;
|
|
10
|
+
return (filePath) => {
|
|
11
|
+
const cached = cache.get(filePath);
|
|
12
|
+
if (cached !== undefined)
|
|
13
|
+
return cached;
|
|
14
|
+
// The project walk produces descendants of one absolute root. Prefix
|
|
15
|
+
// removal avoids parsing both absolute paths for that dominant case.
|
|
16
|
+
const nativeResult = filePath === projectRoot ? ""
|
|
17
|
+
: filePath.startsWith(rootPrefix) ? filePath.slice(rootPrefix.length)
|
|
18
|
+
: relative(projectRoot, filePath);
|
|
19
|
+
const result = sep === "/" ? nativeResult : nativeResult.split(sep).join("/");
|
|
20
|
+
cache.set(filePath, result);
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Cache paths are normalized project-relative POSIX paths. Parse the root
|
|
25
|
+
// once because a warm read restores every file and resolved target.
|
|
26
|
+
export function makeAbsolutePosix(projectRoot) {
|
|
27
|
+
const windowsRoot = /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(projectRoot);
|
|
28
|
+
const normalizedRoot = windowsRoot ? projectRoot.replace(/\\/g, "/") : projectRoot;
|
|
29
|
+
function splitAbsolute(path) {
|
|
30
|
+
const drive = /^([A-Za-z]:)\/(.*)$/.exec(path);
|
|
31
|
+
if (drive !== null)
|
|
32
|
+
return { anchor: `${drive[1]}/`, parts: drive[2].split("/").filter(Boolean) };
|
|
33
|
+
const unc = /^\/\/([^/]+)\/([^/]+)\/?(.*)$/.exec(path);
|
|
34
|
+
if (unc !== null)
|
|
35
|
+
return { anchor: `//${unc[1]}/${unc[2]}/`, parts: unc[3].split("/").filter(Boolean) };
|
|
36
|
+
return { anchor: "/", parts: path.replace(/^\/+/, "").split("/").filter(Boolean) };
|
|
37
|
+
}
|
|
38
|
+
function appendNormalized(anchor, baseParts, path) {
|
|
39
|
+
const parts = [...baseParts];
|
|
40
|
+
for (const part of path.split("/")) {
|
|
41
|
+
if (part === "" || part === ".")
|
|
42
|
+
continue;
|
|
43
|
+
if (part === "..")
|
|
44
|
+
parts.pop();
|
|
45
|
+
else
|
|
46
|
+
parts.push(part);
|
|
47
|
+
}
|
|
48
|
+
return anchor + parts.join("/");
|
|
49
|
+
}
|
|
50
|
+
const root = splitAbsolute(normalizedRoot);
|
|
51
|
+
return (relativePath) => {
|
|
52
|
+
const normalized = windowsRoot ? relativePath.replace(/\\/g, "/") : relativePath;
|
|
53
|
+
if (/^(?:[A-Za-z]:\/|\/)/.test(normalized)) {
|
|
54
|
+
const absolute = splitAbsolute(normalized);
|
|
55
|
+
return appendNormalized(absolute.anchor, absolute.parts, "");
|
|
56
|
+
}
|
|
57
|
+
return appendNormalized(root.anchor, root.parts, normalized);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Responsibility: a thrown config or usage failure that carries the same
|
|
2
|
+
// `do` a violation already carries. The CLI prints both, text or JSON,
|
|
3
|
+
// so this path is not the one report that omits the command to run.
|
|
4
|
+
// Boundary: the message and the next command only. Formatting belongs to
|
|
5
|
+
// the CLI.
|
|
6
|
+
export class ReportError extends Error {
|
|
7
|
+
do;
|
|
8
|
+
constructor(message, doText) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ReportError";
|
|
11
|
+
this.do = doText;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { withPointerSpecs } from "../config-pointer.js";
|
|
2
|
+
// Each entry gets one decision; no later triage of a larger candidate set filters out model noise.
|
|
3
|
+
// Use a conservative 0.7 bar instead of a casual 0.5 guess; weaker contradictions remain undecided because model noise can resemble a problem.
|
|
4
|
+
const CONTRADICTION_THRESHOLD = 0.7;
|
|
5
|
+
// Fixed instructions compare each entry's JSON shape with its own because text to assess the config's internal consistency.
|
|
6
|
+
// They do not assess design fitness against source code; empty-rule.ts checks structural applicability against real graph edges.
|
|
7
|
+
const STATE = "This is an architecture-linting config for a TypeScript project. " +
|
|
8
|
+
"allowDeny restricts which tags may depend on which; order enforces a layer sequence; point forbids specific from/to edges. " +
|
|
9
|
+
"Every entry has a mandatory, human-written because text that explains its intent. " +
|
|
10
|
+
"Assess whether each rule's configured shape contradicts its own because text.";
|
|
11
|
+
const QUESTION = "Does this rule's configured shape agree with (consistent) or contradict (contradicts) " +
|
|
12
|
+
"what its because text claims the rule restricts?";
|
|
13
|
+
const CRITERIA = {
|
|
14
|
+
consistent: "the shape agrees with what the because text claims",
|
|
15
|
+
contradicts: "the shape actually permits something the because text says must never happen",
|
|
16
|
+
};
|
|
17
|
+
export class ProverFailure extends Error {
|
|
18
|
+
kind;
|
|
19
|
+
constructor(kind) {
|
|
20
|
+
super(kind);
|
|
21
|
+
this.kind = kind;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isTimeout(error) {
|
|
25
|
+
return typeof error === "object" && error !== null && "name" in error &&
|
|
26
|
+
(error.name === "AbortError" || error.name === "TimeoutError");
|
|
27
|
+
}
|
|
28
|
+
export const realProver = async (request) => {
|
|
29
|
+
const key = process.env.TYPESAFE_API_KEY;
|
|
30
|
+
if (!key)
|
|
31
|
+
throw new ProverFailure("missing-key");
|
|
32
|
+
const response = await fetch("https://api.typesafe.ai/v1/systemone", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ ...request, model: "jev-latest" }),
|
|
36
|
+
signal: AbortSignal.timeout(5000),
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new ProverFailure("http-status");
|
|
40
|
+
try {
|
|
41
|
+
return await response.json();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (isTimeout(error))
|
|
45
|
+
throw error;
|
|
46
|
+
throw new ProverFailure("invalid-json");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
// HTTP errors can echo request details, including credentials; proving otherwise for every failure path is costly.
|
|
50
|
+
// Fixed, authored reasons prevent caught request details from leaking TYPESAFE_API_KEY into evidence without repeated sanitization.
|
|
51
|
+
// Error categories can select a reason, but evidence must never copy error.message or error.name.
|
|
52
|
+
function skipped(config, reason, doText) {
|
|
53
|
+
return [withPointerSpecs({ rule: "config-meaning", path: config.configPath, line: 1, column: 1,
|
|
54
|
+
tier: "calibrated", skipped: true, evidence: reason,
|
|
55
|
+
because: "a rule that checks nothing must not look like a pass", do: doText }, [{ pointer: "edges", role: "governs" }])];
|
|
56
|
+
}
|
|
57
|
+
function record(value) {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
function probability(value) {
|
|
61
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
62
|
+
}
|
|
63
|
+
// Every finding reports at `config.configPath` (`skipped`/`record` above
|
|
64
|
+
// both fix `path: config.configPath`) - never a single file's own edge.
|
|
65
|
+
// check() itself, not this function, skips calling checkConfigMeaning
|
|
66
|
+
// entirely on a `check <file>` run whose focus isn't the config file:
|
|
67
|
+
// unlike every other rule, a real call here can cost a paid, networked
|
|
68
|
+
// prove request, so that decision happens before the request goes out at
|
|
69
|
+
// all, not after - it would otherwise still be filtered away by runRules'
|
|
70
|
+
// own end-of-call filter, but only after paying for it.
|
|
71
|
+
export async function checkConfigMeaning(config, prove, prover = realProver) {
|
|
72
|
+
// Other tools can share this credential. Only --prove authorizes paid calls for this invocation.
|
|
73
|
+
// A key alone must not cause paid requests during routine CI or pre-commit checks.
|
|
74
|
+
if (!prove)
|
|
75
|
+
return [];
|
|
76
|
+
// Kind and index provide unique keys within one batch request and response; stable identities serve no purpose here.
|
|
77
|
+
// These keys are neither persisted nor compared across checks, and this config-level check never uses todo fingerprints.
|
|
78
|
+
const entries = [
|
|
79
|
+
...(config.edges?.allowDeny ?? []).map((rule, i) => ({ id: `allowDeny-${i}`, kind: "allowDeny", rule, pointer: `edges.allowDeny[${i}]`,
|
|
80
|
+
label: `allowDeny rule (source '${rule.source}', targetNamespace '${rule.targetNamespace}')` })),
|
|
81
|
+
...(config.edges?.order ?? []).map((rule, i) => ({ id: `order-${i}`, kind: "order", rule, pointer: `edges.order[${i}]`,
|
|
82
|
+
label: `order rule (tagNamespace '${rule.tagNamespace}', within ${JSON.stringify(rule.within ?? null)}, sequence ${JSON.stringify(rule.sequence)})` })),
|
|
83
|
+
...(config.edges?.point ?? []).map((rule, i) => ({ id: `point-${i}`, kind: "point", rule, pointer: `edges.point[${i}]`,
|
|
84
|
+
label: `point rule (from ${JSON.stringify(rule.from)}, to ${JSON.stringify(rule.to)})` })),
|
|
85
|
+
];
|
|
86
|
+
if (entries.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
const questions = Object.fromEntries(entries.map(({ id, kind, rule }) => [id, {
|
|
89
|
+
type: "choice",
|
|
90
|
+
instructions: `${JSON.stringify({ kind, ...rule })}\nbecause: ${rule.because}\n${QUESTION}`,
|
|
91
|
+
criteria: { ...CRITERIA },
|
|
92
|
+
}]));
|
|
93
|
+
try {
|
|
94
|
+
const response = await prover({ state: STATE, questions });
|
|
95
|
+
// Every expected answer must satisfy the response contract before we report any assessment.
|
|
96
|
+
// One skip marks an incomplete batch; partial findings would require guessing which answers to trust after a contract failure.
|
|
97
|
+
if (!record(response) || !record(response.answers)) {
|
|
98
|
+
return skipped(config, "the Jev API request failed: invalid answers", "retry archstrict check --prove after checking the service response");
|
|
99
|
+
}
|
|
100
|
+
const assessments = [];
|
|
101
|
+
for (const { id } of entries) {
|
|
102
|
+
const answer = response.answers[id];
|
|
103
|
+
if (!Object.hasOwn(response.answers, id)) {
|
|
104
|
+
return skipped(config, "the Jev API request failed: missing an expected answer", "retry archstrict check --prove after checking the service response");
|
|
105
|
+
}
|
|
106
|
+
if (!record(answer) || answer.type !== "choice" ||
|
|
107
|
+
(answer.choice !== "consistent" && answer.choice !== "contradicts") || !probability(answer.confidence) ||
|
|
108
|
+
!record(answer.probabilities) || !probability(answer.probabilities.consistent) || !probability(answer.probabilities.contradicts)) {
|
|
109
|
+
return skipped(config, "the Jev API request failed: invalid choice answer", "retry archstrict check --prove after checking the service response");
|
|
110
|
+
}
|
|
111
|
+
assessments.push({ type: "choice", choice: answer.choice, confidence: answer.confidence,
|
|
112
|
+
probabilities: { consistent: answer.probabilities.consistent, contradicts: answer.probabilities.contradicts } });
|
|
113
|
+
}
|
|
114
|
+
return entries.flatMap(({ rule, label, pointer }, i) => {
|
|
115
|
+
const { choice, confidence, probabilities } = assessments[i];
|
|
116
|
+
if (choice === "consistent")
|
|
117
|
+
return [];
|
|
118
|
+
// A skip means the request never ran or failed to yield a valid assessment; it cannot establish agreement with the reason.
|
|
119
|
+
// Here Jev answered contradicts successfully, but its confidence does not justify a finding.
|
|
120
|
+
// A separate undecided category preserves this inconclusive assessment instead of hiding it among request failures.
|
|
121
|
+
if (confidence < CONTRADICTION_THRESHOLD) {
|
|
122
|
+
return [withPointerSpecs({ rule: "config-meaning", path: config.configPath, line: 1, column: 1,
|
|
123
|
+
evidence: `${label}: Jev returned contradicts but could not decide with sufficient confidence (confidence ${confidence}; probabilities ${JSON.stringify(probabilities)})`,
|
|
124
|
+
because: rule.because,
|
|
125
|
+
do: `request a human review of the ${label} in archstrict.config.ts: the automated check could not decide`,
|
|
126
|
+
undecided: true, tier: "calibrated" }, [{ pointer, role: "fired" }])];
|
|
127
|
+
}
|
|
128
|
+
return [withPointerSpecs({ rule: "config-meaning", path: config.configPath, line: 1, column: 1,
|
|
129
|
+
evidence: `${label}: Jev assessed that its configured shape contradicts its 'because' text (confidence ${confidence})`,
|
|
130
|
+
because: rule.because,
|
|
131
|
+
do: `review the ${label} in archstrict.config.ts: correct its configured shape or its because text so they describe the same restriction`,
|
|
132
|
+
confidence, tier: "calibrated" }, [{ pointer, role: "fired" }])];
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof ProverFailure && error.kind === "missing-key") {
|
|
137
|
+
return skipped(config, "TYPESAFE_API_KEY is not set", "set TYPESAFE_API_KEY and re-run archstrict check --prove");
|
|
138
|
+
}
|
|
139
|
+
const reason = isTimeout(error) ? "timeout" : error instanceof ProverFailure && error.kind === "http-status" ? "non-2xx HTTP status" :
|
|
140
|
+
error instanceof ProverFailure && error.kind === "invalid-json" ? "invalid JSON response" : "network error";
|
|
141
|
+
return skipped(config, `the Jev API request failed: ${reason}`, "check service access and retry archstrict check --prove");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Responsibility: the constraint engine over `config.edges` - three
|
|
2
|
+
// shapes, generalizing over tags (src/classify.ts) instead of the fixed
|
|
3
|
+
// module/kind vocabulary rules 1-6 use. `allowDeny` (a source tag's
|
|
4
|
+
// allow/deny list over one target namespace at a time), `order` (a tag
|
|
5
|
+
// namespace's sequence, scoped within another namespace's equal value),
|
|
6
|
+
// and `point` (an explicit from/to edge, glob or tag-predicate on either
|
|
7
|
+
// side). One shared idea across all three: a rule scoped to a namespace
|
|
8
|
+
// says nothing about a target with no tag in that namespace - that's rule
|
|
9
|
+
// 3's ("uncovered") territory, not this rule's concern.
|
|
10
|
+
//
|
|
11
|
+
// Composition rule (the one sentence an implementer would otherwise get
|
|
12
|
+
// wrong): each `allowDeny`/`order` rule is evaluated independently, blind
|
|
13
|
+
// to every other rule's namespace. An edge violates if ANY ONE applicable
|
|
14
|
+
// rule says no - a plane-scoped rule and a domain-scoped rule can each
|
|
15
|
+
// clear the same edge or each independently condemn it; neither knows the
|
|
16
|
+
// other exists.
|
|
17
|
+
//
|
|
18
|
+
// `edgeType`/`importForm` filter which edges a rule can match at all,
|
|
19
|
+
// checked before the rule's own from/to or allow/deny logic runs. Default
|
|
20
|
+
// "both" for each - unfiltered, matching every prior ticket's edges.
|
|
21
|
+
// Also identifies allow lists that cover every real target value in the graph.
|
|
22
|
+
// Boundary: pure predicates over a ModuleGraph and a Config, same as every
|
|
23
|
+
// other rule file. No I/O, no output formatting, no todo handling.
|
|
24
|
+
import { computeMoves } from "./moves.js";
|
|
25
|
+
import { compileGlob } from "../classify.js";
|
|
26
|
+
import { classifyFile } from "../classify.js";
|
|
27
|
+
import {} from "../module-graph.js";
|
|
28
|
+
import { ReportError } from "../report-error.js";
|
|
29
|
+
import { withPointerSpecs } from "../config-pointer.js";
|
|
30
|
+
export function formatPredicate(predicate) {
|
|
31
|
+
return typeof predicate === "string" ? predicate : JSON.stringify(predicate);
|
|
32
|
+
}
|
|
33
|
+
// A resolved edge's target tags: a synthesized `pkg:<name>` tag when the
|
|
34
|
+
// edge reaches outside this project entirely (a real npm package, a
|
|
35
|
+
// workspace dependency, a node builtin - module-graph.ts's own
|
|
36
|
+
// `externalPackage`), or the classified tags of the real file otherwise.
|
|
37
|
+
//
|
|
38
|
+
// A package shipping no bundled type declarations of its own resolves
|
|
39
|
+
// through its own `@types/<name>` shadow package instead - TypeScript's
|
|
40
|
+
// own resolver, not this project's choice - so `externalPackage` carries
|
|
41
|
+
// that shadow identity, not the bare specifier a rule author actually
|
|
42
|
+
// wrote. Measured directly, against a real project: two ordinary
|
|
43
|
+
// packages resolved to their own real name; two others (shipping no
|
|
44
|
+
// bundled types) resolved to their own `@types/` identity instead, so a
|
|
45
|
+
// deny/allow/point rule written against the bare name matched zero real
|
|
46
|
+
// edges - silently, with `evaluated` still nonzero (the edge WAS judged,
|
|
47
|
+
// just against the wrong identity), so rule 4's own empty-rule-set check
|
|
48
|
+
// could never have caught it. Both identities are tagged so a rule
|
|
49
|
+
// written against either one matches the same real edge.
|
|
50
|
+
function tagsForTarget(edge, config, relativePath) {
|
|
51
|
+
if (edge.externalPackage !== undefined) {
|
|
52
|
+
const tags = new Set([`pkg:${edge.externalPackage}`]);
|
|
53
|
+
const barePackage = bareNameFromTypesPackage(edge.externalPackage);
|
|
54
|
+
if (barePackage !== undefined)
|
|
55
|
+
tags.add(`pkg:${barePackage}`);
|
|
56
|
+
// A node builtin's own resolvedFile is synthesized as "node:<name>"
|
|
57
|
+
// (module-graph.ts's own convention - no real file exists for one) -
|
|
58
|
+
// the one reliable signal distinguishing it from a real npm package,
|
|
59
|
+
// whose resolvedFile is always a genuine filesystem path. Without
|
|
60
|
+
// this, a rule author who wants to ban every Node builtin from a
|
|
61
|
+
// browser-runtime layer or similar has to enumerate each bare name
|
|
62
|
+
// (pkg:fs, pkg:path, ...) individually, which silently under-protects
|
|
63
|
+
// against a future builtin nobody thought to add when writing the
|
|
64
|
+
// rule - a real, measured case, authoring a rule against a real
|
|
65
|
+
// bundler tool's own source. `pkg:node` matches every builtin at
|
|
66
|
+
// once; a rule naming one specific builtin still works exactly as
|
|
67
|
+
// before, since its own bare-name tag is unchanged.
|
|
68
|
+
if (edge.resolvedFile.startsWith("node:"))
|
|
69
|
+
tags.add("pkg:node");
|
|
70
|
+
return tags;
|
|
71
|
+
}
|
|
72
|
+
return classifyFile(relativePath(edge.resolvedFile), config);
|
|
73
|
+
}
|
|
74
|
+
// DefinitelyTyped's own naming convention: an unscoped package "foo" ships
|
|
75
|
+
// as "@types/foo"; a scoped package "@scope/foo" ships as
|
|
76
|
+
// "@types/scope__foo" (a literal double underscore standing in for the
|
|
77
|
+
// slash, since npm package names can't nest a real "/" under a scope
|
|
78
|
+
// beyond the scope itself).
|
|
79
|
+
function bareNameFromTypesPackage(packageName) {
|
|
80
|
+
if (!packageName.startsWith("@types/"))
|
|
81
|
+
return undefined;
|
|
82
|
+
const rest = packageName.slice("@types/".length);
|
|
83
|
+
const scopeSplit = rest.indexOf("__");
|
|
84
|
+
return scopeSplit === -1 ? rest : `@${rest.slice(0, scopeSplit)}/${rest.slice(scopeSplit + 2)}`;
|
|
85
|
+
}
|
|
86
|
+
// A glob can only match a real project-relative path - an external
|
|
87
|
+
// target's `resolvedFile` (node_modules, or a synthesized "node:x" for a
|
|
88
|
+
// builtin) has no such path, so a string `to`/`from` predicate simply
|
|
89
|
+
// never matches an external edge; only a tag predicate (matching the
|
|
90
|
+
// synthesized `pkg:` tag) can.
|
|
91
|
+
function targetRelPathForGlob(edge, relativePath) {
|
|
92
|
+
return edge.externalPackage !== undefined ? undefined : relativePath(edge.resolvedFile);
|
|
93
|
+
}
|
|
94
|
+
function matchesEdgeFilters(edge, edgeType, importForm) {
|
|
95
|
+
if (edgeType === "value" && edge.isTypeOnly)
|
|
96
|
+
return false;
|
|
97
|
+
if (edgeType === "type" && !edge.isTypeOnly)
|
|
98
|
+
return false;
|
|
99
|
+
if (importForm === "static" && edge.isDynamic)
|
|
100
|
+
return false;
|
|
101
|
+
if (importForm === "dynamic" && !edge.isDynamic)
|
|
102
|
+
return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
export function matchesPredicate(predicate, relPath, tags) {
|
|
106
|
+
if (typeof predicate === "string") {
|
|
107
|
+
return relPath !== undefined && compileGlob(predicate).test(relPath);
|
|
108
|
+
}
|
|
109
|
+
if (!predicate.tags.every((t) => tags.has(t)))
|
|
110
|
+
return false;
|
|
111
|
+
if (predicate.exclude !== undefined && predicate.exclude.tags.every((t) => tags.has(t)))
|
|
112
|
+
return false;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
export function isExemptedByGlobPair(edge, exceptions, relativePath) {
|
|
116
|
+
if (exceptions === undefined || exceptions.length === 0)
|
|
117
|
+
return false;
|
|
118
|
+
const fromRel = relativePath(edge.fromFile);
|
|
119
|
+
const toRel = targetRelPathForGlob(edge, relativePath);
|
|
120
|
+
return exceptions.some((ex) => compileGlob(ex.from).test(fromRel) && toRel !== undefined && compileGlob(ex.to).test(toRel));
|
|
121
|
+
}
|
|
122
|
+
// `graph.edges` follows the edge build's own walk order (rootNames order -
|
|
123
|
+
// a directory scan, not a promise about reading order across files), not
|
|
124
|
+
// a promise about output order - checkAllowDeny/checkOrder/checkPoint
|
|
125
|
+
// (not their own compute* helpers, which return each match's own
|
|
126
|
+
// ruleIndex/coverage bookkeeping alongside it, order and all) sort by
|
|
127
|
+
// this before returning their own violations, so that output stays
|
|
128
|
+
// stable regardless of it. Code-unit order (`<`/`>`), not localeCompare:
|
|
129
|
+
// a locale-aware compare can order the same two paths differently on
|
|
130
|
+
// different machines.
|
|
131
|
+
function byPosition(a, b) {
|
|
132
|
+
return (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) || a.line - b.line || a.column - b.column;
|
|
133
|
+
}
|
|
134
|
+
// `focus`, threaded through all three of this file's own rule shapes
|
|
135
|
+
// (allowDeny/order/point below), is check()'s own realpath'd target for a
|
|
136
|
+
// `check <file>` run - every one of the three reports at `edge.fromFile`
|
|
137
|
+
// (compared directly, not through `resolve()`: an edge's own `fromFile` is
|
|
138
|
+
// always already an absolute, real path, the same invariant
|
|
139
|
+
// checkPublicSurfaceBypass's own comment already documents), never at any
|
|
140
|
+
// other path. Unlike rule 1, `focus` here only gates what gets pushed into
|
|
141
|
+
// `violations`/`matches` - the loop still runs over every real edge in
|
|
142
|
+
// `graph.edges` regardless, because `evaluatedCounts` (and so
|
|
143
|
+
// `EdgeRuleCoverage.evaluated`, which checkEdgesCoverage's own callers use
|
|
144
|
+
// unscoped - checkEmptyRuleSet's own vacuous-rule check needs the real,
|
|
145
|
+
// whole-project count, not a count of one file's own edges) has to stay a
|
|
146
|
+
// whole-project fact either way.
|
|
147
|
+
export function computeAllowDeny(graph, config, focus) {
|
|
148
|
+
const rules = config.edges?.allowDeny ?? [];
|
|
149
|
+
const relativePath = graph.relativePath;
|
|
150
|
+
const violations = [];
|
|
151
|
+
const matches = [];
|
|
152
|
+
const evaluatedCounts = rules.map(() => 0);
|
|
153
|
+
if (rules.length === 0)
|
|
154
|
+
return { violations, coverage: [], matches };
|
|
155
|
+
for (const edge of graph.edges) {
|
|
156
|
+
const sourceTags = classifyFile(relativePath(edge.fromFile), config);
|
|
157
|
+
if (sourceTags.size === 0)
|
|
158
|
+
continue;
|
|
159
|
+
const targetTags = tagsForTarget(edge, config, relativePath);
|
|
160
|
+
if (targetTags.size === 0)
|
|
161
|
+
continue;
|
|
162
|
+
rules.forEach((rule, i) => {
|
|
163
|
+
if (!matchesEdgeFilters(edge, rule.edgeType, rule.importForm))
|
|
164
|
+
return;
|
|
165
|
+
if (!sourceTags.has(rule.source))
|
|
166
|
+
return;
|
|
167
|
+
if (targetTags.has(rule.source))
|
|
168
|
+
return; // same group as source: unconstrained by this rule
|
|
169
|
+
if (isExemptedByGlobPair(edge, rule.exceptions, relativePath))
|
|
170
|
+
return;
|
|
171
|
+
const namespacePrefix = `${rule.targetNamespace}:`;
|
|
172
|
+
const targetValues = [...targetTags].filter((t) => t.startsWith(namespacePrefix));
|
|
173
|
+
if (targetValues.length === 0)
|
|
174
|
+
return; // no tag in this namespace: not this rule's concern
|
|
175
|
+
evaluatedCounts[i]++; // this rule genuinely had a real edge to judge, whatever the verdict below
|
|
176
|
+
let violatingTag;
|
|
177
|
+
if (rule.allow !== undefined) {
|
|
178
|
+
const allowed = new Set(rule.allow.map((v) => `${namespacePrefix}${v}`));
|
|
179
|
+
violatingTag = targetValues.find((v) => !allowed.has(v));
|
|
180
|
+
}
|
|
181
|
+
else if (rule.deny !== undefined) {
|
|
182
|
+
const denied = new Set(rule.deny.map((v) => `${namespacePrefix}${v}`));
|
|
183
|
+
violatingTag = targetValues.find((v) => denied.has(v));
|
|
184
|
+
}
|
|
185
|
+
if (violatingTag === undefined)
|
|
186
|
+
return;
|
|
187
|
+
if (focus !== undefined && edge.fromFile !== focus)
|
|
188
|
+
return; // evaluated (coverage counted above); not built for a scoped run
|
|
189
|
+
const pointers = rule.deny === undefined
|
|
190
|
+
? [{ pointer: `edges.allowDeny[${i}].allow`, role: "fired" }]
|
|
191
|
+
: [
|
|
192
|
+
{ pointer: `edges.allowDeny[${i}].deny[${rule.deny.indexOf(violatingTag.slice(namespacePrefix.length))}]`, role: "fired" },
|
|
193
|
+
{ pointer: `edges.allowDeny[${i}].allow`, role: "edit-here" },
|
|
194
|
+
];
|
|
195
|
+
const violation = withPointerSpecs({
|
|
196
|
+
rule: "tag-boundary",
|
|
197
|
+
path: edge.fromFile,
|
|
198
|
+
line: edge.fromPosition.line,
|
|
199
|
+
column: edge.fromPosition.column,
|
|
200
|
+
evidence: `'${edge.specifier}' (from '${rule.source}') reaches '${violatingTag}'`,
|
|
201
|
+
because: rule.because,
|
|
202
|
+
do: `remove this edge, or add '${violatingTag.slice(namespacePrefix.length)}' to '${rule.source}'s allow list in archstrict.config.ts and record why`,
|
|
203
|
+
todoModule: edge.fromModule,
|
|
204
|
+
}, pointers);
|
|
205
|
+
violations.push(violation);
|
|
206
|
+
matches.push({ violation, edge, ruleIndex: i, violatingTag });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const coverage = rules.map((rule, i) => ({
|
|
210
|
+
kind: "allowDeny",
|
|
211
|
+
identifier: `${rule.source} -> ${rule.targetNamespace}`,
|
|
212
|
+
evaluated: evaluatedCounts[i],
|
|
213
|
+
}));
|
|
214
|
+
return { violations, coverage, matches };
|
|
215
|
+
}
|
|
216
|
+
export function targetTagsInGraph(graph, config) {
|
|
217
|
+
const tags = new Set();
|
|
218
|
+
for (const edge of graph.edges) {
|
|
219
|
+
for (const tag of tagsForTarget(edge, config, graph.relativePath))
|
|
220
|
+
tags.add(tag);
|
|
221
|
+
}
|
|
222
|
+
return tags;
|
|
223
|
+
}
|
|
224
|
+
// Use the whole graph: a value reached only by another source still gives
|
|
225
|
+
// this rule something to forbid if its source later imports that value.
|
|
226
|
+
// Looking only at the source's current edges would mistake a healthy rule
|
|
227
|
+
// for an exhaustive list whenever those edges happen to obey it.
|
|
228
|
+
// Exclude the source tag itself: computeAllowDeny exempts targets carrying
|
|
229
|
+
// that tag, so this value cannot be a violation candidate for this rule.
|
|
230
|
+
// exceptions, edgeType, and importForm select edges to judge, not values
|
|
231
|
+
// that exist. An out-of-list value prevents exhaustiveness even when only
|
|
232
|
+
// exempted, type-only, or dynamic edges currently reach it.
|
|
233
|
+
// Apply this check only to allow lists. A deny list can legitimately name
|
|
234
|
+
// a value that does not exist yet to guard against a future regression.
|
|
235
|
+
export function checkExhaustiveAllow(graph, config) {
|
|
236
|
+
const rules = config.edges?.allowDeny ?? [];
|
|
237
|
+
if (!rules.some(rule => rule.allow !== undefined))
|
|
238
|
+
return [];
|
|
239
|
+
const { coverage } = computeAllowDeny(graph, config);
|
|
240
|
+
const allTargetTags = targetTagsInGraph(graph, config);
|
|
241
|
+
return rules.flatMap((rule, i) => {
|
|
242
|
+
if (rule.allow === undefined || coverage[i].evaluated === 0)
|
|
243
|
+
return [];
|
|
244
|
+
const prefix = `${rule.targetNamespace}:`;
|
|
245
|
+
const universe = [...allTargetTags].filter(tag => tag.startsWith(prefix) && tag !== rule.source);
|
|
246
|
+
const allowed = new Set(rule.allow.map(value => `${prefix}${value}`));
|
|
247
|
+
return universe.length > 0 && universe.every(tag => allowed.has(tag))
|
|
248
|
+
? [{ identifier: coverage[i].identifier, rule, ruleId: "exhaustive-allow-list" }] : [];
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
export function checkAllowDeny(graph, config, focus) {
|
|
252
|
+
return computeAllowDeny(graph, config, focus).matches.map(match => {
|
|
253
|
+
const moves = computeMoves(match.violation, graph, config, match);
|
|
254
|
+
return moves?.length ? Object.assign(match.violation, { moves }) : match.violation;
|
|
255
|
+
}).sort(byPosition);
|
|
256
|
+
}
|
|
257
|
+
// Two different things, confirmed distinct by running against Prisma's own
|
|
258
|
+
// real config: a `within` value absent from `sequence` ENTIRELY (e.g.
|
|
259
|
+
// Prisma's own layerOrder has no "targets" or "extensions" entry at all -
|
|
260
|
+
// those domains simply have no internal layering declared) is not this
|
|
261
|
+
// rule's concern for that domain - silently out of scope, not an error.
|
|
262
|
+
// A `within` value that DOES have a sequence, but doesn't list this
|
|
263
|
+
// specific layer value, is the real config error: classify assigned a
|
|
264
|
+
// value the config's author forgot to place.
|
|
265
|
+
export function sequenceFor(rule, withinValue) {
|
|
266
|
+
return rule.sequence[withinValue ?? ""];
|
|
267
|
+
}
|
|
268
|
+
export function assertSequenceListsValue(rule, withinValue, sequence, tag) {
|
|
269
|
+
const value = tag.slice(rule.tagNamespace.length + 1);
|
|
270
|
+
if (!sequence.includes(value)) {
|
|
271
|
+
throw new ReportError(`order rule for '${rule.tagNamespace}' (within '${withinValue ?? "(unscoped)"}') does not list '${value}' - every value classify assigns within that scope must appear in its sequence`, `add '${value}' to that order rule's sequence in archstrict.config.ts, then run archstrict check`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// See computeAllowDeny's own comment above for `focus`'s meaning here:
|
|
275
|
+
// reports at `edge.fromFile` too, and `evaluatedCounts`/coverage stay
|
|
276
|
+
// whole-project regardless of it.
|
|
277
|
+
function computeOrder(graph, config, focus) {
|
|
278
|
+
const rules = config.edges?.order ?? [];
|
|
279
|
+
const relativePath = graph.relativePath;
|
|
280
|
+
const violations = [];
|
|
281
|
+
const evaluatedCounts = rules.map(() => 0);
|
|
282
|
+
if (rules.length === 0)
|
|
283
|
+
return { violations, coverage: [] };
|
|
284
|
+
for (const edge of graph.edges) {
|
|
285
|
+
const sourceTags = classifyFile(relativePath(edge.fromFile), config);
|
|
286
|
+
const targetTags = tagsForTarget(edge, config, relativePath);
|
|
287
|
+
rules.forEach((rule, i) => {
|
|
288
|
+
if (!matchesEdgeFilters(edge, rule.edgeType, rule.importForm))
|
|
289
|
+
return;
|
|
290
|
+
const namespacePrefix = `${rule.tagNamespace}:`;
|
|
291
|
+
const sourceLayer = [...sourceTags].find((t) => t.startsWith(namespacePrefix));
|
|
292
|
+
const targetLayer = [...targetTags].find((t) => t.startsWith(namespacePrefix));
|
|
293
|
+
if (sourceLayer === undefined || targetLayer === undefined)
|
|
294
|
+
return;
|
|
295
|
+
let withinValue;
|
|
296
|
+
if (rule.within !== undefined) {
|
|
297
|
+
const withinPrefix = `${rule.within}:`;
|
|
298
|
+
const sourceWithin = [...sourceTags].find((t) => t.startsWith(withinPrefix));
|
|
299
|
+
const targetWithin = [...targetTags].find((t) => t.startsWith(withinPrefix));
|
|
300
|
+
if (sourceWithin === undefined || targetWithin === undefined)
|
|
301
|
+
return;
|
|
302
|
+
if (sourceWithin !== targetWithin)
|
|
303
|
+
return; // different scope entirely: this order rule doesn't cross it
|
|
304
|
+
withinValue = sourceWithin.slice(withinPrefix.length);
|
|
305
|
+
}
|
|
306
|
+
const sequence = sequenceFor(rule, withinValue);
|
|
307
|
+
if (sequence === undefined)
|
|
308
|
+
return; // this within-value has no declared sequence at all: out of scope, not an error
|
|
309
|
+
assertSequenceListsValue(rule, withinValue, sequence, sourceLayer);
|
|
310
|
+
assertSequenceListsValue(rule, withinValue, sequence, targetLayer);
|
|
311
|
+
evaluatedCounts[i]++; // this rule genuinely had a real edge, within a real declared sequence, to judge
|
|
312
|
+
const sourceIndex = sequence.indexOf(sourceLayer.slice(namespacePrefix.length));
|
|
313
|
+
const targetIndex = sequence.indexOf(targetLayer.slice(namespacePrefix.length));
|
|
314
|
+
// "downward-only": a source may depend on its own layer or one
|
|
315
|
+
// closer to the sequence's start (index 0 = innermost/core); reaching
|
|
316
|
+
// a later index moves away from core, which is forbidden. Matches
|
|
317
|
+
// dependency-cruiser's own generator: forbidden iff targetIndex >
|
|
318
|
+
// sourceIndex.
|
|
319
|
+
if (targetIndex <= sourceIndex)
|
|
320
|
+
return;
|
|
321
|
+
if (focus !== undefined && edge.fromFile !== focus)
|
|
322
|
+
return; // evaluated (coverage counted above); not built for a scoped run
|
|
323
|
+
violations.push(withPointerSpecs({
|
|
324
|
+
rule: "tag-order",
|
|
325
|
+
path: edge.fromFile,
|
|
326
|
+
line: edge.fromPosition.line,
|
|
327
|
+
column: edge.fromPosition.column,
|
|
328
|
+
evidence: `'${edge.specifier}' reaches '${targetLayer}' from '${sourceLayer}' (${rule.tagNamespace} sequence: ${sequence.join(" -> ")})`,
|
|
329
|
+
because: rule.because,
|
|
330
|
+
do: `move this edge to depend only on '${rule.tagNamespace}' values at or before '${sourceLayer.slice(namespacePrefix.length)}' in archstrict.config.ts's sequence, or restructure the code so it does`,
|
|
331
|
+
todoModule: edge.fromModule,
|
|
332
|
+
}, [{ pointer: `edges.order[${i}].sequence`, role: "fired" }]));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const coverage = rules.map((rule, i) => ({
|
|
336
|
+
kind: "order",
|
|
337
|
+
identifier: `${rule.tagNamespace}${rule.within !== undefined ? ` within ${rule.within}` : ""}`,
|
|
338
|
+
evaluated: evaluatedCounts[i],
|
|
339
|
+
}));
|
|
340
|
+
return { violations, coverage };
|
|
341
|
+
}
|
|
342
|
+
export function checkOrder(graph, config, focus) {
|
|
343
|
+
return computeOrder(graph, config, focus).violations.sort(byPosition);
|
|
344
|
+
}
|
|
345
|
+
// Unlike allowDeny/order (which have an "applicable but allowed" middle
|
|
346
|
+
// state), a point rule's from/to predicates ARE the whole rule - any edge
|
|
347
|
+
// whose from side matches is a real opportunity for this rule to fire,
|
|
348
|
+
// whether or not the to side happens to match as well. So "evaluated"
|
|
349
|
+
// here means "the from predicate matched a real edge", the strongest
|
|
350
|
+
// vacuousness signal point can offer: a from glob/tags that never matches
|
|
351
|
+
// anything real is a rule that can never fire, and a to side that never
|
|
352
|
+
// matches doesn't make the rule vacuous on its own (it may be correctly
|
|
353
|
+
// finding zero forbidden edges among real, matched-from-side candidates).
|
|
354
|
+
// See computeAllowDeny's own comment above for `focus`'s meaning here:
|
|
355
|
+
// reports at `edge.fromFile` too, and `evaluatedCounts`/coverage stay
|
|
356
|
+
// whole-project regardless of it.
|
|
357
|
+
function computePoint(graph, config, focus) {
|
|
358
|
+
const rules = config.edges?.point ?? [];
|
|
359
|
+
const identifiers = rules.map((rule) => `${formatPredicate(rule.from)} -> ${formatPredicate(rule.to)}`);
|
|
360
|
+
const relativePath = graph.relativePath;
|
|
361
|
+
const violations = [];
|
|
362
|
+
const evaluatedCounts = rules.map(() => 0);
|
|
363
|
+
if (rules.length === 0)
|
|
364
|
+
return { violations, coverage: [] };
|
|
365
|
+
for (const edge of graph.edges) {
|
|
366
|
+
const sourceRel = relativePath(edge.fromFile);
|
|
367
|
+
const sourceTags = classifyFile(sourceRel, config);
|
|
368
|
+
const targetTags = tagsForTarget(edge, config, relativePath);
|
|
369
|
+
const targetRel = targetRelPathForGlob(edge, relativePath);
|
|
370
|
+
rules.forEach((rule, i) => {
|
|
371
|
+
if (!matchesEdgeFilters(edge, rule.edgeType, rule.importForm))
|
|
372
|
+
return;
|
|
373
|
+
if (!matchesPredicate(rule.from, sourceRel, sourceTags))
|
|
374
|
+
return;
|
|
375
|
+
evaluatedCounts[i]++;
|
|
376
|
+
if (!matchesPredicate(rule.to, targetRel, targetTags))
|
|
377
|
+
return;
|
|
378
|
+
if (focus !== undefined && edge.fromFile !== focus)
|
|
379
|
+
return; // evaluated (coverage counted above); not built for a scoped run
|
|
380
|
+
violations.push(withPointerSpecs({
|
|
381
|
+
rule: "point-rule",
|
|
382
|
+
path: edge.fromFile,
|
|
383
|
+
line: edge.fromPosition.line,
|
|
384
|
+
column: edge.fromPosition.column,
|
|
385
|
+
evidence: `'${edge.specifier}' matches a forbidden edge`,
|
|
386
|
+
because: rule.because,
|
|
387
|
+
do: `remove this edge, or narrow the point rule '${identifiers[i]}' in archstrict.config.ts if it's too broad`,
|
|
388
|
+
todoModule: edge.fromModule,
|
|
389
|
+
}, [{ pointer: `edges.point[${i}]`, role: "fired" }]));
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const coverage = identifiers.map((identifier, i) => ({
|
|
393
|
+
kind: "point",
|
|
394
|
+
identifier,
|
|
395
|
+
evaluated: evaluatedCounts[i],
|
|
396
|
+
}));
|
|
397
|
+
return { violations, coverage };
|
|
398
|
+
}
|
|
399
|
+
export function checkPoint(graph, config, focus) {
|
|
400
|
+
return computePoint(graph, config, focus).violations.sort(byPosition);
|
|
401
|
+
}
|
|
402
|
+
export function checkConstraints(graph, config) {
|
|
403
|
+
return [...checkAllowDeny(graph, config), ...checkOrder(graph, config), ...checkPoint(graph, config)];
|
|
404
|
+
}
|
|
405
|
+
// All configured allowDeny/order/point rules, each with how many real
|
|
406
|
+
// edges reached the point where it could have judged one - not just
|
|
407
|
+
// whether it violated. checkEmptyRuleSet (rule 4) uses this to flag a
|
|
408
|
+
// rule that structurally never applies to anything, the same "a rule
|
|
409
|
+
// that checks nothing must not look like a pass" idea rule 4 already
|
|
410
|
+
// applies to classify/declaredModules.
|
|
411
|
+
export function checkEdgesCoverage(graph, config) {
|
|
412
|
+
return [
|
|
413
|
+
...computeAllowDeny(graph, config).coverage,
|
|
414
|
+
...computeOrder(graph, config).coverage,
|
|
415
|
+
...computePoint(graph, config).coverage,
|
|
416
|
+
];
|
|
417
|
+
}
|