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.
Files changed (65) hide show
  1. package/.agents/hooks/hooks.json +29 -0
  2. package/.agents/hooks/post-tool-use.mjs +107 -0
  3. package/.agents/hooks/pre-tool-use.mjs +182 -0
  4. package/.agents/mcp/server.mjs +71 -0
  5. package/.agents/plugin.json +19 -0
  6. package/AGENTS.md +69 -0
  7. package/CHANGELOG.md +38 -0
  8. package/README.ja.md +62 -0
  9. package/README.md +63 -2
  10. package/dist/augmentation-cache.js +65 -0
  11. package/dist/check-options.js +40 -0
  12. package/dist/classify.js +148 -0
  13. package/dist/cli.js +239 -0
  14. package/dist/config-pointer.js +251 -0
  15. package/dist/config.js +186 -0
  16. package/dist/edge-cache.js +530 -0
  17. package/dist/mcp-server.js +111 -0
  18. package/dist/module-candidates.js +118 -0
  19. package/dist/module-graph.js +2072 -0
  20. package/dist/project-path.js +59 -0
  21. package/dist/report-error.js +13 -0
  22. package/dist/rules/config-meaning.js +143 -0
  23. package/dist/rules/constraints.js +417 -0
  24. package/dist/rules/cycles.js +257 -0
  25. package/dist/rules/deprecated.js +67 -0
  26. package/dist/rules/empty-rule.js +101 -0
  27. package/dist/rules/moves.js +79 -0
  28. package/dist/rules/must-be-empty.js +52 -0
  29. package/dist/rules/public-surface.js +100 -0
  30. package/dist/rules/type-leak.js +562 -0
  31. package/dist/rules/uncovered.js +75 -0
  32. package/dist/todo-migration.js +112 -0
  33. package/dist/todo-store.js +434 -0
  34. package/dist/type-closure.js +959 -0
  35. package/dist/verbs/agents.js +116 -0
  36. package/dist/verbs/check.js +957 -0
  37. package/dist/verbs/fix.js +170 -0
  38. package/dist/verbs/hotspots.js +261 -0
  39. package/dist/verbs/init.js +522 -0
  40. package/dist/verbs/recommend.js +800 -0
  41. package/dist/verbs/rules.js +188 -0
  42. package/dist/verbs/search.js +109 -0
  43. package/dist/verbs/simulate.js +220 -0
  44. package/dist/verbs/todo.js +163 -0
  45. package/dist/warm-graph.js +82 -0
  46. package/docs/boundary-patterns.md +374 -0
  47. package/docs/calibrated-rules-design.md +124 -0
  48. package/docs/init-singleton-modules.md +128 -0
  49. package/docs/maintenance.md +82 -0
  50. package/docs/releasing.md +55 -0
  51. package/docs/rules-edge-cache.md +50 -0
  52. package/docs/todo-single-file-migration.md +58 -0
  53. package/llms.txt +19 -0
  54. package/package.json +57 -4
  55. package/skills/archstrict/SKILL.md +42 -0
  56. package/skills/archstrict/references/agents-verb.md +39 -0
  57. package/skills/archstrict/references/config.md +107 -0
  58. package/skills/archstrict/references/hook.md +57 -0
  59. package/skills/archstrict/references/path-rules.md +57 -0
  60. package/skills/archstrict/references/patterns.md +883 -0
  61. package/skills/archstrict/references/prove-rules.md +58 -0
  62. package/skills/archstrict/references/rearchitect.md +35 -0
  63. package/skills/archstrict/references/recommend.md +80 -0
  64. package/skills/archstrict/references/rules.md +146 -0
  65. package/skills/archstrict/references/simulate.md +109 -0
@@ -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
+ }
package/dist/config.js ADDED
@@ -0,0 +1,186 @@
1
+ import { ReportError } from "./report-error.js";
2
+ // The only schema loadConfig accepts. init writes this value into a new
3
+ // archstrict.config.ts. A config that omits the field is this same schema
4
+ // (the field arrived after the first configs); any other value is a config
5
+ // error, not a silent misread of a future shape.
6
+ export const SCHEMA_VERSION = 1;
7
+ // Checked before required-field validation: a future schema may rename
8
+ // those fields, and the version mismatch is the fact to report first.
9
+ export function assertSchemaVersion(configPath, raw) {
10
+ if (!("schemaVersion" in raw))
11
+ return;
12
+ const version = raw.schemaVersion;
13
+ if (version === SCHEMA_VERSION)
14
+ return;
15
+ throw new ReportError(`${configPath} schemaVersion ${JSON.stringify(version)} is not supported; this archstrict reads schemaVersion ${SCHEMA_VERSION}`, `set schemaVersion to ${SCHEMA_VERSION} in ${configPath}, then run archstrict check`);
16
+ }
17
+ // Throws if any `deprecated` entry names a module that doesn't exist.
18
+ // Shared by rule 4 and rule 5: without a single shared check, the two
19
+ // rules can disagree about the same config. Measured: rule 4's own
20
+ // zero-modules early return skips its `deprecated` loop entirely, so a
21
+ // `deprecated` entry naming a nonexistent module reached rule 4's "count
22
+ // is 0, edge no longer exists" case instead of a config error — a name
23
+ // that never existed is not the same fact as an edge that used to exist
24
+ // and shrank to nothing, and reporting it that way is misleading, not
25
+ // just imprecise.
26
+ export function assertDeprecatedModulesExist(graph, config) {
27
+ for (const entry of config.deprecated ?? []) {
28
+ for (const moduleName of [entry.from, entry.to]) {
29
+ if (!graph.modules.has(moduleName)) {
30
+ throw new ReportError(`deprecated entry '${entry.from} -> ${entry.to}' names module '${moduleName}', which does not exist`, `declare '${moduleName}' in ${config.configPath}, or remove that deprecated entry, then run archstrict check`);
31
+ }
32
+ }
33
+ }
34
+ }
35
+ // loadConfig reads a real config file with ts.transpileModule (strips
36
+ // types, never fully type-checks - see loadConfig's own comment for why),
37
+ // so a malformed `edges` value is otherwise invisible to both the type
38
+ // system and every rule: writing `edges` as an array instead of the real
39
+ // `{ allowDeny?, order?, point? }` object produces zero rules, zero
40
+ // violations, and - critically - no empty-rule-set violation either
41
+ // (rule 4 has nothing to see, since no rule was ever parsed into
42
+ // existence), indistinguishable from a config that never used `edges` at
43
+ // all. Measured directly, via a fresh agent authoring a real config from
44
+ // scratch: this was the single silent failure among several very similar
45
+ // ones (an `order` entry's own `sequence` written as a flat array instead
46
+ // of `Record<string, string[]>`, a real, unsupported key mistyped onto a
47
+ // rule entry) - the `sequence` case happens to surface today via rule 4's
48
+ // own `evaluated: 0`, but neither it nor an unsupported key should depend
49
+ // on a downstream rule noticing a side effect. A config shape error is a
50
+ // config error, thrown up front, the same as an unsupported `deprecated`
51
+ // entry already is above.
52
+ function isPlainObject(value) {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+ // Exported: loadConfig's own declaredModules validation (check.ts) needs
56
+ // the same wording for the same kind of shape mismatch, rather than a
57
+ // second, differently-worded describer for the same fact.
58
+ export function describeShape(value) {
59
+ return Array.isArray(value) ? "an array" : typeof value;
60
+ }
61
+ function assertKnownKeys(value, known, context) {
62
+ for (const key of Object.keys(value)) {
63
+ if (!known.includes(key)) {
64
+ throw new ReportError(`${context} has an unknown field '${key}' - supported fields are ${known.join(", ")}`, `remove '${key}' from ${context} in archstrict.config.ts, then run archstrict check`);
65
+ }
66
+ }
67
+ }
68
+ function assertEntries(value, keys, context) {
69
+ if (value === undefined)
70
+ return [];
71
+ if (!Array.isArray(value)) {
72
+ throw new ReportError(`config.edges.${context} must be an array of entries, not ${describeShape(value)}`, `set config.edges.${context} to an array of entries in archstrict.config.ts, then run archstrict check`);
73
+ }
74
+ return value.map((entry, i) => {
75
+ if (!isPlainObject(entry)) {
76
+ throw new ReportError(`config.edges.${context}[${i}] must be an object, not ${describeShape(entry)}`, `make config.edges.${context}[${i}] an object in archstrict.config.ts, then run archstrict check`);
77
+ }
78
+ assertKnownKeys(entry, keys, `config.edges.${context}[${i}]`);
79
+ return entry;
80
+ });
81
+ }
82
+ const ALLOW_DENY_KEYS = [
83
+ "source",
84
+ "targetNamespace",
85
+ "allow",
86
+ "deny",
87
+ "exceptions",
88
+ "edgeType",
89
+ "importForm",
90
+ "because",
91
+ ];
92
+ const ORDER_KEYS = ["tagNamespace", "within", "sequence", "direction", "edgeType", "importForm", "because"];
93
+ const POINT_KEYS = ["from", "to", "edgeType", "importForm", "because"];
94
+ export function assertEdgesShapeValid(config) {
95
+ const edges = config.edges;
96
+ if (edges === undefined)
97
+ return;
98
+ if (!isPlainObject(edges)) {
99
+ throw new ReportError(`config.edges must be an object with allowDeny/order/point fields (e.g. { allowDeny: [...] }), not ${describeShape(edges)}`, "set config.edges to an object with allowDeny, order, and point in archstrict.config.ts, then run archstrict check");
100
+ }
101
+ assertKnownKeys(edges, ["allowDeny", "order", "point"], "config.edges");
102
+ for (const entry of assertEntries(edges.allowDeny, ALLOW_DENY_KEYS, "allowDeny")) {
103
+ // These shapes cannot reject any edge, even when coverage is nonzero.
104
+ if (entry.allow === undefined &&
105
+ (entry.deny === undefined || (Array.isArray(entry.deny) && entry.deny.length === 0))) {
106
+ throw new Error(`config.edges.allowDeny entry with source '${entry.source}' and targetNamespace '${entry.targetNamespace}' must specify allow or a non-empty deny list`);
107
+ }
108
+ }
109
+ assertEntries(edges.point, POINT_KEYS, "point");
110
+ for (const entry of assertEntries(edges.order, ORDER_KEYS, "order")) {
111
+ const sequence = entry.sequence;
112
+ if (sequence !== undefined && !isPlainObject(sequence)) {
113
+ throw new ReportError(`an edges.order entry's sequence must be an object keyed by the 'within' scope (e.g. { "": ["a", "b"] }), not ${describeShape(sequence)}`, "set that sequence to an object keyed by the within scope in archstrict.config.ts, then run archstrict check");
114
+ }
115
+ }
116
+ }
117
+ // compileGlob (classify.ts) only ever special-cases `*` and `**`; every
118
+ // other character - including brace (`{a,b}`), extglob (`+(a|b)`,
119
+ // `@(...)`, `!(...)`, `?(...)`), `?`, and bracket (`[...]`) syntax a shell
120
+ // or a real glob library would treat specially - falls through its own
121
+ // literal branch, escaped for RegExp use. A config author who writes one
122
+ // of those, expecting shell/minimatch semantics, gets a glob that matches
123
+ // nothing: every file it was meant to cover instead surfaces as
124
+ // uncovered-module, silently, with no hint the glob itself was the
125
+ // problem. Caught here, once, for every field a glob can appear in,
126
+ // rather than as a downstream "why is this file uncovered" mystery.
127
+ //
128
+ // A bare `+` or `@` is left alone (both appear in ordinary literal paths -
129
+ // a scoped package directory name, a filename with a `+` in it); only the
130
+ // bracket/brace/question-mark/bang characters below are checked, and `(`
131
+ // alone already catches the extglob forms (`+(`, `@(`, `!(`, `?(`) without
132
+ // needing to special-case them.
133
+ const UNSUPPORTED_GLOB_PATTERN = /[{}()[\]?!]/;
134
+ function assertGlobSupported(configPath, field, glob, verb) {
135
+ // A non-string value here is a different validator's problem (shape
136
+ // checks above, or the field's own type in Config) - this check only
137
+ // ever looks at strings that already made it this far.
138
+ if (typeof glob !== "string" || !UNSUPPORTED_GLOB_PATTERN.test(glob))
139
+ return;
140
+ throw new ReportError(`${configPath} field '${field}' has an unsupported glob '${glob}' - only '*' (any characters within one path segment) and '**' (any depth, including zero segments) are supported; '{', '}', '(', ')', '[', ']', '?', and '!' all match nothing, including in an extglob form like '+(...)' or '@(...)'`, `rewrite '${field}' in ${configPath} using only * and **, or split it into one entry per directory, in archstrict.config.ts, then run ${verb}`);
141
+ }
142
+ // One entry per glob-bearing field the config schema has (see Config's own
143
+ // fields above). Walked defensively (typeof/Array.isArray guards, not the
144
+ // Config type) because loadConfig calls this on a value ts.transpileModule
145
+ // only stripped types from, never type-checked - a field can hold any
146
+ // runtime shape a hand-written config puts there.
147
+ export function assertGlobsSupported(config, verb) {
148
+ const configPath = config.configPath;
149
+ for (const [i, glob] of (config.exclude ?? []).entries()) {
150
+ assertGlobSupported(configPath, `exclude[${i}]`, glob, verb);
151
+ }
152
+ for (const [i, entry] of (config.classify ?? []).entries()) {
153
+ assertGlobSupported(configPath, `classify[${i}].glob`, entry?.glob, verb);
154
+ }
155
+ for (const [i, entry] of (config.mustBeEmpty ?? []).entries()) {
156
+ assertGlobSupported(configPath, `mustBeEmpty[${i}].glob`, entry?.glob, verb);
157
+ }
158
+ for (const [i, mod] of (config.declaredModules ?? []).entries()) {
159
+ assertGlobSupported(configPath, `declaredModules[${i}].glob`, mod?.glob, verb);
160
+ const surface = mod?.surface;
161
+ if (Array.isArray(surface)) {
162
+ for (const [j, s] of surface.entries()) {
163
+ assertGlobSupported(configPath, `declaredModules[${i}].surface[${j}]`, s, verb);
164
+ }
165
+ }
166
+ else if (surface !== undefined) {
167
+ assertGlobSupported(configPath, `declaredModules[${i}].surface`, surface, verb);
168
+ }
169
+ for (const [j, friend] of (mod?.friends ?? []).entries()) {
170
+ assertGlobSupported(configPath, `declaredModules[${i}].friends[${j}].file`, friend?.file, verb);
171
+ assertGlobSupported(configPath, `declaredModules[${i}].friends[${j}].from`, friend?.from, verb);
172
+ }
173
+ }
174
+ for (const [i, rule] of (config.edges?.allowDeny ?? []).entries()) {
175
+ for (const [j, exception] of (rule?.exceptions ?? []).entries()) {
176
+ assertGlobSupported(configPath, `edges.allowDeny[${i}].exceptions[${j}].from`, exception?.from, verb);
177
+ assertGlobSupported(configPath, `edges.allowDeny[${i}].exceptions[${j}].to`, exception?.to, verb);
178
+ }
179
+ }
180
+ // point's from/to are each either a glob (string) or a tag predicate
181
+ // (an object) - only the string form is a glob this check applies to.
182
+ for (const [i, rule] of (config.edges?.point ?? []).entries()) {
183
+ assertGlobSupported(configPath, `edges.point[${i}].from`, rule?.from, verb);
184
+ assertGlobSupported(configPath, `edges.point[${i}].to`, rule?.to, verb);
185
+ }
186
+ }