@canonical/webarchitect 0.26.0 → 0.27.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@canonical/webarchitect",
3
3
  "description": "A tool to test the compliance with architecture specifications for packages and applications.",
4
- "version": "0.26.0",
4
+ "version": "0.27.0",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
7
7
  "types": "src/index.ts",
@@ -33,21 +33,22 @@
33
33
  "check:biome": "biome check",
34
34
  "check:biome:fix": "biome check --write",
35
35
  "check:ts": "tsc --noEmit",
36
- "test": "echo 'No tests defined yet'",
36
+ "test": "vitest run",
37
37
  "check:webarchitect": "bun run src/cli.ts tool-ts"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@biomejs/biome": "2.4.9",
41
- "@canonical/biome-config": "^0.26.0",
42
- "@canonical/typescript-config": "^0.26.0",
41
+ "@canonical/biome-config": "^0.27.0",
42
+ "@canonical/typescript-config": "^0.27.0",
43
43
  "@types/json-schema": "^7.0.15",
44
44
  "@types/node": "^24.12.0",
45
- "typescript": "^5.9.3"
45
+ "typescript": "^5.9.3",
46
+ "vitest": "^4.0.18"
46
47
  },
47
48
  "dependencies": {
48
49
  "ajv": "^8.18.0",
49
50
  "chalk": "^5.6.2",
50
51
  "commander": "^14.0.3"
51
52
  },
52
- "gitHead": "55fece1380dba8dcd3a88af443dd509d9aaaccb2"
53
+ "gitHead": "5df5709bf7cb6f2016be2f6eba8391637e2e2a0c"
53
54
  }
@@ -0,0 +1,40 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { BUNDLED_RULESETS_DIR } from "./constants.js";
4
+
5
+ describe("constants", () => {
6
+ beforeEach(() => {
7
+ vi.resetModules();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.doUnmock("node:fs");
12
+ });
13
+
14
+ it("resolves to srcPath when srcPath exists", async () => {
15
+ vi.doMock("node:fs", () => ({
16
+ existsSync: vi.fn().mockReturnValue(true),
17
+ }));
18
+ const { BUNDLED_RULESETS_DIR: dir } = await import("./constants.js");
19
+ // srcPath is join(dirname, "../rulesets") — one level up
20
+ expect(dir).toMatch(/rulesets$/);
21
+ expect(dir).toBe(BUNDLED_RULESETS_DIR);
22
+ });
23
+
24
+ it("resolves to distPath when srcPath does not exist", async () => {
25
+ vi.doMock("node:fs", () => ({
26
+ existsSync: vi.fn().mockReturnValue(false),
27
+ }));
28
+ const { BUNDLED_RULESETS_DIR: dir } = await import("./constants.js");
29
+ // distPath is join(dirname, "../../rulesets") — two levels up, different from srcPath
30
+ expect(dir).toMatch(/rulesets$/);
31
+ expect(dir).not.toBe(BUNDLED_RULESETS_DIR);
32
+ });
33
+
34
+ it("resolved directory contains bundled ruleset files", async () => {
35
+ const files = await readdir(BUNDLED_RULESETS_DIR);
36
+ const rulesets = files.filter((f) => f.endsWith(".ruleset.json"));
37
+ expect(rulesets.length).toBeGreaterThan(0);
38
+ expect(rulesets).toContain("base.ruleset.json");
39
+ });
40
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import ajv from "./ajv.js";
3
+
4
+ describe("ajv", () => {
5
+ it("validates regex format with valid regex", () => {
6
+ const validate = ajv.compile({
7
+ type: "string",
8
+ format: "regex",
9
+ });
10
+ expect(validate("^foo$")).toBe(true);
11
+ });
12
+
13
+ it("rejects invalid regex", () => {
14
+ const validate = ajv.compile({
15
+ type: "string",
16
+ format: "regex",
17
+ });
18
+ expect(validate("[invalid")).toBe(false);
19
+ });
20
+
21
+ it("validates email format", () => {
22
+ const validate = ajv.compile({
23
+ type: "string",
24
+ format: "email",
25
+ });
26
+ expect(validate("test@example.com")).toBe(true);
27
+ expect(validate("notanemail")).toBe(false);
28
+ });
29
+
30
+ it("validates uri format", () => {
31
+ const validate = ajv.compile({
32
+ type: "string",
33
+ format: "uri",
34
+ });
35
+ expect(validate("https://example.com")).toBe(true);
36
+ });
37
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import describeSchema from "./describeSchema.js";
3
+
4
+ describe("describeSchema", () => {
5
+ it("returns type description for string type", () => {
6
+ expect(describeSchema({ type: "object" })).toBe("must be object");
7
+ });
8
+
9
+ it("returns type description for array type", () => {
10
+ expect(describeSchema({ type: ["string", "number"] })).toBe(
11
+ "must be one of: string, number",
12
+ );
13
+ });
14
+
15
+ it("returns const description", () => {
16
+ expect(describeSchema({ const: "module" })).toBe('must equal "module"');
17
+ });
18
+
19
+ it("returns pattern description", () => {
20
+ expect(describeSchema({ pattern: "^@canonical/" })).toBe(
21
+ "must match pattern /^@canonical//",
22
+ );
23
+ });
24
+
25
+ it("returns required properties description", () => {
26
+ expect(describeSchema({ required: ["name", "version"] })).toBe(
27
+ "must have properties: name, version",
28
+ );
29
+ });
30
+
31
+ it("returns properties description for <=3 properties", () => {
32
+ expect(
33
+ describeSchema({
34
+ properties: { name: {}, version: {} },
35
+ }),
36
+ ).toBe("expected properties: name, version");
37
+ });
38
+
39
+ it("returns count for >3 properties", () => {
40
+ expect(
41
+ describeSchema({
42
+ properties: { a: {}, b: {}, c: {}, d: {} },
43
+ }),
44
+ ).toBe("validates 4 properties");
45
+ });
46
+
47
+ it("ignores non-string non-array type", () => {
48
+ // type as a non-standard value (neither string nor array) — falls through both if/else-if
49
+ expect(describeSchema({ type: 42 } as any)).toBe(
50
+ "validates file content structure",
51
+ );
52
+ });
53
+
54
+ it("returns default description for empty schema", () => {
55
+ expect(describeSchema({})).toBe("validates file content structure");
56
+ });
57
+
58
+ it("combines multiple descriptions", () => {
59
+ const result = describeSchema({
60
+ type: "object",
61
+ required: ["name"],
62
+ properties: { name: {} },
63
+ });
64
+ expect(result).toBe(
65
+ "must be object, must have properties: name, expected properties: name",
66
+ );
67
+ });
68
+ });
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import discoverAllRulesets from "./discoverAllRulesets.js";
6
+
7
+ describe("discoverAllRulesets", () => {
8
+ it("finds bundled rulesets including 'base'", async () => {
9
+ const result = await discoverAllRulesets();
10
+ expect(result.bundled.length).toBeGreaterThan(0);
11
+ expect(result.bundled.some((r) => r.name === "base")).toBe(true);
12
+ expect(result.bundled.every((r) => r.type === "bundled")).toBe(true);
13
+ expect(result.bundled.every((r) => r.path.endsWith(".ruleset.json"))).toBe(
14
+ true,
15
+ );
16
+ });
17
+
18
+ it("discovers local rulesets from current working directory", async () => {
19
+ const tmp = join(tmpdir(), `webarchitect-discover-all-${Date.now()}`);
20
+ mkdirSync(tmp, { recursive: true });
21
+ writeFileSync(
22
+ join(tmp, "custom.ruleset.json"),
23
+ JSON.stringify({ name: "custom" }),
24
+ );
25
+ const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmp);
26
+ try {
27
+ const result = await discoverAllRulesets();
28
+ expect(result.local.some((r) => r.name === "custom")).toBe(true);
29
+ expect(result.local.find((r) => r.name === "custom")?.type).toBe("local");
30
+ } finally {
31
+ cwdSpy.mockRestore();
32
+ rmSync(tmp, { recursive: true, force: true });
33
+ }
34
+ });
35
+ });
@@ -0,0 +1,61 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import discoverRulesetsInDir from "./discoverRulesetsInDir.js";
6
+
7
+ describe("discoverRulesetsInDir", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = join(tmpdir(), `webarchitect-discover-${Date.now()}`);
12
+ mkdirSync(tmp, { recursive: true });
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tmp, { recursive: true, force: true });
17
+ });
18
+
19
+ it("discovers .ruleset.json files as bundled", async () => {
20
+ writeFileSync(
21
+ join(tmp, "test.ruleset.json"),
22
+ JSON.stringify({ name: "test" }),
23
+ );
24
+ const results = await discoverRulesetsInDir(tmp, "bundled");
25
+ expect(results).toHaveLength(1);
26
+ expect(results[0].name).toBe("test");
27
+ expect(results[0].type).toBe("bundled");
28
+ expect(results[0].path).toBe(join(tmp, "test.ruleset.json"));
29
+ });
30
+
31
+ it("discovers local rulesets with JSON validation", async () => {
32
+ writeFileSync(
33
+ join(tmp, "valid.ruleset.json"),
34
+ JSON.stringify({ name: "valid" }),
35
+ );
36
+ writeFileSync(join(tmp, "invalid.ruleset.json"), "not json{");
37
+ const results = await discoverRulesetsInDir(tmp, "local");
38
+ expect(results).toHaveLength(1);
39
+ expect(results[0].name).toBe("valid");
40
+ });
41
+
42
+ it("skips non-ruleset files", async () => {
43
+ writeFileSync(join(tmp, "readme.md"), "# docs");
44
+ writeFileSync(join(tmp, "config.json"), "{}");
45
+ const results = await discoverRulesetsInDir(tmp, "bundled");
46
+ expect(results).toEqual([]);
47
+ });
48
+
49
+ it("returns empty array for non-existent directory", async () => {
50
+ const results = await discoverRulesetsInDir(
51
+ join(tmp, "nonexistent"),
52
+ "bundled",
53
+ );
54
+ expect(results).toEqual([]);
55
+ });
56
+
57
+ it("returns empty array for empty directory", async () => {
58
+ const results = await discoverRulesetsInDir(tmp, "bundled");
59
+ expect(results).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,88 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import type { Schema } from "../types.js";
6
+ import executeValidationRules from "./executeValidationRules.js";
7
+
8
+ describe("executeValidationRules", () => {
9
+ let tmp: string;
10
+
11
+ beforeEach(() => {
12
+ tmp = join(tmpdir(), `webarchitect-exec-${Date.now()}`);
13
+ mkdirSync(tmp, { recursive: true });
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tmp, { recursive: true, force: true });
18
+ });
19
+
20
+ it("skips meta-properties ($schema, name, extends)", async () => {
21
+ const schema: Schema = {
22
+ $schema: "http://example.com",
23
+ name: "test-schema",
24
+ extends: ["base"],
25
+ };
26
+ const results = await executeValidationRules(tmp, schema);
27
+ expect(results).toEqual([]);
28
+ });
29
+
30
+ it("skips non-rule values", async () => {
31
+ const schema = {
32
+ name: "test",
33
+ notARule: "string-value",
34
+ alsoNotARule: 42,
35
+ } as unknown as Schema;
36
+ const results = await executeValidationRules(tmp, schema);
37
+ expect(results).toEqual([]);
38
+ });
39
+
40
+ it("executes file rules", async () => {
41
+ writeFileSync(join(tmp, "pkg.json"), JSON.stringify({ name: "x" }));
42
+ const schema: Schema = {
43
+ name: "test",
44
+ "pkg-check": {
45
+ file: {
46
+ name: "pkg.json",
47
+ contains: {
48
+ type: "object",
49
+ required: ["name"],
50
+ properties: { name: { type: "string" } },
51
+ },
52
+ },
53
+ },
54
+ };
55
+ const results = await executeValidationRules(tmp, schema);
56
+ expect(results).toHaveLength(1);
57
+ expect(results[0].passed).toBe(true);
58
+ expect(results[0].rule).toBe("pkg-check");
59
+ });
60
+
61
+ it("executes multiple rules in parallel", async () => {
62
+ writeFileSync(join(tmp, "a.json"), JSON.stringify({ v: 1 }));
63
+ writeFileSync(join(tmp, "b.json"), JSON.stringify({ v: 2 }));
64
+ const schema: Schema = {
65
+ name: "multi",
66
+ "rule-a": { file: { name: "a.json", contains: { type: "object" } } },
67
+ "rule-b": { file: { name: "b.json", contains: { type: "object" } } },
68
+ };
69
+ const results = await executeValidationRules(tmp, schema);
70
+ expect(results).toHaveLength(2);
71
+ expect(results.every((r) => r.passed)).toBe(true);
72
+ });
73
+
74
+ it("flattens nested results from directory rules", async () => {
75
+ mkdirSync(join(tmp, "src"));
76
+ writeFileSync(join(tmp, "src", "index.ts"), "");
77
+ const schema: Schema = {
78
+ name: "nested",
79
+ "src-structure": {
80
+ directory: { name: "src" },
81
+ },
82
+ };
83
+ const results = await executeValidationRules(tmp, schema);
84
+ expect(results).toHaveLength(1);
85
+ expect(results[0].rule).toBe("src-structure");
86
+ expect(results[0].passed).toBe(true);
87
+ });
88
+ });
@@ -0,0 +1,42 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import listDirectory from "./listDirectory.js";
6
+
7
+ describe("listDirectory", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = join(tmpdir(), `webarchitect-test-${Date.now()}`);
12
+ mkdirSync(tmp, { recursive: true });
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tmp, { recursive: true, force: true });
17
+ });
18
+
19
+ it("separates files and directories", async () => {
20
+ writeFileSync(join(tmp, "file.txt"), "");
21
+ mkdirSync(join(tmp, "subdir"));
22
+ const result = await listDirectory(tmp);
23
+ expect(result.files).toEqual(["file.txt"]);
24
+ expect(result.directories).toEqual(["subdir"]);
25
+ });
26
+
27
+ it("returns empty arrays for empty directory", async () => {
28
+ const result = await listDirectory(tmp);
29
+ expect(result.files).toEqual([]);
30
+ expect(result.directories).toEqual([]);
31
+ });
32
+
33
+ it("handles multiple entries", async () => {
34
+ writeFileSync(join(tmp, "a.json"), "");
35
+ writeFileSync(join(tmp, "b.txt"), "");
36
+ mkdirSync(join(tmp, "src"));
37
+ mkdirSync(join(tmp, "dist"));
38
+ const result = await listDirectory(tmp);
39
+ expect(result.files.sort()).toEqual(["a.json", "b.txt"]);
40
+ expect(result.directories.sort()).toEqual(["dist", "src"]);
41
+ });
42
+ });
@@ -0,0 +1,137 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import loadFullSchema from "./loadFullSchema.js";
6
+
7
+ describe("loadFullSchema", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = join(tmpdir(), `webarchitect-schema-${Date.now()}`);
12
+ mkdirSync(tmp, { recursive: true });
13
+ vi.spyOn(console, "log").mockImplementation(() => {});
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tmp, { recursive: true, force: true });
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("loads a schema without extends", async () => {
22
+ const schema = {
23
+ name: "simple",
24
+ "my-rule": {
25
+ file: { name: "test.json", contains: { type: "object" } },
26
+ },
27
+ };
28
+ const schemaPath = join(tmp, "simple.ruleset.json");
29
+ writeFileSync(schemaPath, JSON.stringify(schema));
30
+
31
+ const result = await loadFullSchema(schemaPath);
32
+ expect(result.name).toBe("simple");
33
+ const rule = result["my-rule"] as { file: { name: string } };
34
+ expect(rule.file.name).toBe("test.json");
35
+ });
36
+
37
+ it("merges rules from extended schemas", async () => {
38
+ const baseSchema = {
39
+ name: "base",
40
+ "base-rule": {
41
+ file: { name: "base.json", contains: { type: "object" } },
42
+ },
43
+ };
44
+ const childSchema = {
45
+ name: "child",
46
+ extends: [join(tmp, "base.ruleset.json")],
47
+ "child-rule": {
48
+ file: { name: "child.json", contains: { type: "string" } },
49
+ },
50
+ };
51
+
52
+ writeFileSync(join(tmp, "base.ruleset.json"), JSON.stringify(baseSchema));
53
+ writeFileSync(join(tmp, "child.ruleset.json"), JSON.stringify(childSchema));
54
+
55
+ const result = await loadFullSchema(join(tmp, "child.ruleset.json"));
56
+ expect(result.name).toBe("child");
57
+ const base = result["base-rule"] as { file: { name: string } };
58
+ const child = result["child-rule"] as { file: { name: string } };
59
+ expect(base.file.name).toBe("base.json");
60
+ expect(child.file.name).toBe("child.json");
61
+ });
62
+
63
+ it("child rules override parent rules with same name", async () => {
64
+ const parent = {
65
+ name: "parent",
66
+ "shared-rule": {
67
+ file: { name: "parent.json", contains: { type: "string" } },
68
+ },
69
+ };
70
+ const child = {
71
+ name: "child",
72
+ extends: [join(tmp, "parent.ruleset.json")],
73
+ "shared-rule": {
74
+ file: { name: "child.json", contains: { type: "number" } },
75
+ },
76
+ };
77
+
78
+ writeFileSync(join(tmp, "parent.ruleset.json"), JSON.stringify(parent));
79
+ writeFileSync(join(tmp, "child.ruleset.json"), JSON.stringify(child));
80
+
81
+ const result = await loadFullSchema(join(tmp, "child.ruleset.json"));
82
+ const rule = result["shared-rule"] as { file: { name: string } };
83
+ expect(rule.file.name).toBe("child.json");
84
+ });
85
+
86
+ it("preserves $schema and name from child, strips extends", async () => {
87
+ const parent = {
88
+ $schema: "parent-schema",
89
+ name: "parent",
90
+ };
91
+ const child = {
92
+ $schema: "child-schema",
93
+ name: "child",
94
+ extends: [join(tmp, "parent.ruleset.json")],
95
+ };
96
+
97
+ writeFileSync(join(tmp, "parent.ruleset.json"), JSON.stringify(parent));
98
+ writeFileSync(join(tmp, "child.ruleset.json"), JSON.stringify(child));
99
+
100
+ const result = await loadFullSchema(join(tmp, "child.ruleset.json"));
101
+ expect(result.$schema).toBe("child-schema");
102
+ expect(result.name).toBe("child");
103
+ expect(result.extends).toBeUndefined();
104
+ });
105
+
106
+ it("handles multiple extends", async () => {
107
+ const base1 = {
108
+ name: "base1",
109
+ rule1: {
110
+ file: { name: "a.json", contains: { type: "object" } },
111
+ },
112
+ };
113
+ const base2 = {
114
+ name: "base2",
115
+ rule2: {
116
+ file: { name: "b.json", contains: { type: "object" } },
117
+ },
118
+ };
119
+ const child = {
120
+ name: "child",
121
+ extends: [
122
+ join(tmp, "base1.ruleset.json"),
123
+ join(tmp, "base2.ruleset.json"),
124
+ ],
125
+ };
126
+
127
+ writeFileSync(join(tmp, "base1.ruleset.json"), JSON.stringify(base1));
128
+ writeFileSync(join(tmp, "base2.ruleset.json"), JSON.stringify(base2));
129
+ writeFileSync(join(tmp, "child.ruleset.json"), JSON.stringify(child));
130
+
131
+ const result = await loadFullSchema(join(tmp, "child.ruleset.json"));
132
+ const r1 = result.rule1 as { file: { name: string } };
133
+ const r2 = result.rule2 as { file: { name: string } };
134
+ expect(r1.file.name).toBe("a.json");
135
+ expect(r2.file.name).toBe("b.json");
136
+ });
137
+ });
@@ -0,0 +1,135 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import resolveSchema from "./resolveSchema.js";
6
+
7
+ describe("resolveSchema", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = join(tmpdir(), `webarchitect-resolve-${Date.now()}`);
12
+ mkdirSync(tmp, { recursive: true });
13
+ vi.spyOn(console, "log").mockImplementation(() => {});
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tmp, { recursive: true, force: true });
18
+ vi.unstubAllGlobals();
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ it("loads schema from local .ruleset.json file", async () => {
23
+ const schema = { name: "test-local" };
24
+ const path = join(tmp, "local.ruleset.json");
25
+ writeFileSync(path, JSON.stringify(schema));
26
+ const result = await resolveSchema(path);
27
+ expect(result.name).toBe("test-local");
28
+ });
29
+
30
+ it("loads schema from local .json file", async () => {
31
+ const schema = { name: "test-json" };
32
+ const path = join(tmp, "local.json");
33
+ writeFileSync(path, JSON.stringify(schema));
34
+ const result = await resolveSchema(path);
35
+ expect(result.name).toBe("test-json");
36
+ });
37
+
38
+ it("appends .ruleset.json when no extension", async () => {
39
+ const schema = { name: "test-no-ext" };
40
+ writeFileSync(join(tmp, "myschema.ruleset.json"), JSON.stringify(schema));
41
+ const result = await resolveSchema(join(tmp, "myschema"));
42
+ expect(result.name).toBe("test-no-ext");
43
+ });
44
+
45
+ it("falls back to bundled rulesets", async () => {
46
+ const result = await resolveSchema("base");
47
+ expect(result.name).toBe("base");
48
+ });
49
+
50
+ it("throws when schema not found locally or bundled", async () => {
51
+ await expect(
52
+ resolveSchema("nonexistent-schema-that-does-not-exist"),
53
+ ).rejects.toThrow("Could not find ruleset");
54
+ });
55
+
56
+ it("throws when schema fails validation", async () => {
57
+ // Write a file that is valid JSON but not a valid schema
58
+ const path = join(tmp, "invalid.ruleset.json");
59
+ writeFileSync(path, JSON.stringify({ notAValidSchema: true }));
60
+ await expect(resolveSchema(path)).rejects.toThrow("Invalid ruleset");
61
+ });
62
+
63
+ it("loads schema from URL", async () => {
64
+ const schema = { name: "remote" };
65
+ vi.stubGlobal(
66
+ "fetch",
67
+ vi.fn().mockResolvedValue({
68
+ json: () => Promise.resolve(schema),
69
+ }),
70
+ );
71
+ const result = await resolveSchema("https://example.com/schema.json");
72
+ expect(result.name).toBe("remote");
73
+ });
74
+
75
+ it("loads schema from http URL", async () => {
76
+ const schema = { name: "http-remote" };
77
+ vi.stubGlobal(
78
+ "fetch",
79
+ vi.fn().mockResolvedValue({
80
+ json: () => Promise.resolve(schema),
81
+ }),
82
+ );
83
+ const result = await resolveSchema("http://example.com/schema.json");
84
+ expect(result.name).toBe("http-remote");
85
+ });
86
+
87
+ it("error message includes available bundled rulesets", async () => {
88
+ await expect(resolveSchema("nonexistent-schema-xyz-abc")).rejects.toThrow(
89
+ /Could not find ruleset.*Available bundled rulesets/s,
90
+ );
91
+ });
92
+ });
93
+
94
+ describe("resolveSchema validation edge cases (mocked ajv)", () => {
95
+ let tmp: string;
96
+
97
+ beforeEach(() => {
98
+ tmp = join(tmpdir(), `webarchitect-resolve-edge-${Date.now()}`);
99
+ mkdirSync(tmp, { recursive: true });
100
+ vi.resetModules();
101
+ vi.spyOn(console, "log").mockImplementation(() => {});
102
+ });
103
+
104
+ afterEach(() => {
105
+ rmSync(tmp, { recursive: true, force: true });
106
+ vi.doUnmock("./ajv.js");
107
+ vi.restoreAllMocks();
108
+ });
109
+
110
+ it("uses fallback message when AJV error has no message", async () => {
111
+ const mockValidate = Object.assign(() => false, {
112
+ errors: [{ instancePath: "/bad", message: undefined }],
113
+ });
114
+ vi.doMock("./ajv.js", () => ({
115
+ default: { compile: vi.fn().mockReturnValue(mockValidate) },
116
+ }));
117
+ const schemaPath = join(tmp, "edge.ruleset.json");
118
+ writeFileSync(schemaPath, JSON.stringify({ name: "edge" }));
119
+ const { default: fn } = await import("./resolveSchema.js");
120
+ await expect(fn(schemaPath)).rejects.toThrow("validation failed");
121
+ });
122
+
123
+ it("handles null validateSchema.errors gracefully", async () => {
124
+ const mockValidate = Object.assign(() => false, {
125
+ errors: null,
126
+ });
127
+ vi.doMock("./ajv.js", () => ({
128
+ default: { compile: vi.fn().mockReturnValue(mockValidate) },
129
+ }));
130
+ const schemaPath = join(tmp, "null-errors.ruleset.json");
131
+ writeFileSync(schemaPath, JSON.stringify({ name: "null-errors" }));
132
+ const { default: fn } = await import("./resolveSchema.js");
133
+ await expect(fn(schemaPath)).rejects.toThrow("Invalid ruleset");
134
+ });
135
+ });
@@ -0,0 +1,258 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ afterEach,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ type MockInstance,
12
+ vi,
13
+ } from "vitest";
14
+ import validateDirectoryRule from "./validateDirectoryRule.js";
15
+
16
+ vi.mock("node:fs/promises", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("node:fs/promises")>();
18
+ return { ...actual, stat: vi.fn().mockImplementation(actual.stat) };
19
+ });
20
+
21
+ const mockStat = stat as unknown as MockInstance;
22
+
23
+ describe("validateDirectoryRule", () => {
24
+ let tmp: string;
25
+
26
+ beforeEach(() => {
27
+ tmp = join(tmpdir(), `webarchitect-dir-${Date.now()}`);
28
+ mkdirSync(tmp, { recursive: true });
29
+ });
30
+
31
+ afterEach(() => {
32
+ rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ it("passes when directory exists and has no rules", async () => {
36
+ mkdirSync(join(tmp, "src"));
37
+ const results = await validateDirectoryRule(
38
+ tmp,
39
+ { name: "src" },
40
+ "src-dir",
41
+ );
42
+ expect(results).toHaveLength(1);
43
+ expect(results[0].rule).toBe("src-dir");
44
+ expect(results[0].passed).toBe(true);
45
+ });
46
+
47
+ it("soft-fails when directory not found", async () => {
48
+ const results = await validateDirectoryRule(
49
+ tmp,
50
+ { name: "missing" },
51
+ "missing-dir",
52
+ );
53
+ expect(results).toHaveLength(1);
54
+ expect(results[0].passed).toBe(false);
55
+ expect(results[0].message).toContain("Directory not found");
56
+ });
57
+
58
+ it("soft-fails when path is a file instead of directory", async () => {
59
+ writeFileSync(join(tmp, "notadir"), "content");
60
+ const results = await validateDirectoryRule(
61
+ tmp,
62
+ { name: "notadir" },
63
+ "file-as-dir",
64
+ );
65
+ expect(results).toHaveLength(1);
66
+ expect(results[0].passed).toBe(false);
67
+ expect(results[0].message).toContain("Expected directory but found file");
68
+ });
69
+
70
+ it("validates contained files", async () => {
71
+ mkdirSync(join(tmp, "pkg"));
72
+ writeFileSync(
73
+ join(tmp, "pkg", "package.json"),
74
+ JSON.stringify({ name: "test" }),
75
+ );
76
+ const results = await validateDirectoryRule(
77
+ tmp,
78
+ {
79
+ name: "pkg",
80
+ contains: {
81
+ files: [
82
+ {
83
+ name: "package.json",
84
+ contains: {
85
+ type: "object",
86
+ required: ["name"],
87
+ properties: { name: { type: "string" } },
88
+ },
89
+ },
90
+ ],
91
+ },
92
+ },
93
+ "pkg-dir",
94
+ );
95
+ expect(results.length).toBeGreaterThan(0);
96
+ expect(results.every((r) => r.passed)).toBe(true);
97
+ });
98
+
99
+ it("validates contained subdirectories recursively", async () => {
100
+ mkdirSync(join(tmp, "root", "sub"), { recursive: true });
101
+ const results = await validateDirectoryRule(
102
+ tmp,
103
+ {
104
+ name: "root",
105
+ contains: {
106
+ directories: [{ name: "sub" }],
107
+ },
108
+ },
109
+ "recursive",
110
+ );
111
+ expect(results.length).toBeGreaterThan(0);
112
+ expect(results.every((r) => r.passed)).toBe(true);
113
+ });
114
+
115
+ it("strict mode detects extra files", async () => {
116
+ mkdirSync(join(tmp, "strict"));
117
+ writeFileSync(join(tmp, "strict", "expected.json"), "{}");
118
+ writeFileSync(join(tmp, "strict", "extra.txt"), "");
119
+ const results = await validateDirectoryRule(
120
+ tmp,
121
+ {
122
+ name: "strict",
123
+ strict: true,
124
+ contains: {
125
+ files: [{ name: "expected.json", contains: { type: "object" } }],
126
+ },
127
+ },
128
+ "strict-dir",
129
+ );
130
+ const strictResult = results.find((r) =>
131
+ r.message?.includes("extra files"),
132
+ );
133
+ expect(strictResult).toBeDefined();
134
+ expect(strictResult?.passed).toBe(false);
135
+ expect(strictResult?.message).toContain("extra.txt");
136
+ });
137
+
138
+ it("strict mode detects extra directories", async () => {
139
+ mkdirSync(join(tmp, "strict2"));
140
+ mkdirSync(join(tmp, "strict2", "unexpected"));
141
+ const results = await validateDirectoryRule(
142
+ tmp,
143
+ {
144
+ name: "strict2",
145
+ strict: true,
146
+ },
147
+ "strict2-dir",
148
+ );
149
+ const strictResult = results.find((r) =>
150
+ r.message?.includes("extra directories"),
151
+ );
152
+ expect(strictResult).toBeDefined();
153
+ expect(strictResult?.passed).toBe(false);
154
+ });
155
+
156
+ it("strict mode detects both extra files and directories", async () => {
157
+ mkdirSync(join(tmp, "strict3"));
158
+ writeFileSync(join(tmp, "strict3", "extra.txt"), "");
159
+ mkdirSync(join(tmp, "strict3", "extradir"));
160
+ const results = await validateDirectoryRule(
161
+ tmp,
162
+ { name: "strict3", strict: true },
163
+ "strict3-dir",
164
+ );
165
+ const strictResult = results.find((r) =>
166
+ r.message?.includes("extra files"),
167
+ );
168
+ expect(strictResult).toBeDefined();
169
+ expect(strictResult?.message).toContain("extra directories");
170
+ });
171
+
172
+ it("strict mode passes when no extra entries", async () => {
173
+ mkdirSync(join(tmp, "clean"));
174
+ writeFileSync(join(tmp, "clean", "expected.json"), "{}");
175
+ const results = await validateDirectoryRule(
176
+ tmp,
177
+ {
178
+ name: "clean",
179
+ strict: true,
180
+ contains: {
181
+ files: [{ name: "expected.json", contains: { type: "object" } }],
182
+ },
183
+ },
184
+ "clean-dir",
185
+ );
186
+ expect(results.length).toBeGreaterThan(0);
187
+ expect(results.every((r) => r.passed)).toBe(true);
188
+ });
189
+
190
+ it("strict mode with only extra dirs (no extra files)", async () => {
191
+ mkdirSync(join(tmp, "onlydirs"));
192
+ mkdirSync(join(tmp, "onlydirs", "extra"));
193
+ const results = await validateDirectoryRule(
194
+ tmp,
195
+ {
196
+ name: "onlydirs",
197
+ strict: true,
198
+ contains: {},
199
+ },
200
+ "onlydirs",
201
+ );
202
+ const strictResult = results.find((r) =>
203
+ r.message?.includes("extra directories"),
204
+ );
205
+ expect(strictResult).toBeDefined();
206
+ expect(strictResult?.message).not.toContain("extra files");
207
+ });
208
+
209
+ it("strict mode with expected directories passes", async () => {
210
+ mkdirSync(join(tmp, "withsub"));
211
+ mkdirSync(join(tmp, "withsub", "expected"));
212
+ const results = await validateDirectoryRule(
213
+ tmp,
214
+ {
215
+ name: "withsub",
216
+ strict: true,
217
+ contains: {
218
+ directories: [{ name: "expected" }],
219
+ },
220
+ },
221
+ "withsub-dir",
222
+ );
223
+ expect(results.length).toBeGreaterThan(0);
224
+ expect(results.every((r) => r.passed)).toBe(true);
225
+ });
226
+
227
+ it("includes context with directory type", async () => {
228
+ mkdirSync(join(tmp, "ctx"));
229
+ const results = await validateDirectoryRule(
230
+ tmp,
231
+ { name: "ctx" },
232
+ "ctx-dir",
233
+ );
234
+ expect(results).toHaveLength(1);
235
+ expect(results[0].context?.type).toBe("directory");
236
+ expect(results[0].context?.target).toBe(join(tmp, "ctx"));
237
+ });
238
+ });
239
+
240
+ describe("validateDirectoryRule error paths", () => {
241
+ it("throws permission denied for EACCES on stat", async () => {
242
+ mockStat.mockRejectedValueOnce(
243
+ Object.assign(new Error("EACCES"), { code: "EACCES" }),
244
+ );
245
+ await expect(
246
+ validateDirectoryRule("/project", { name: "restricted" }, "perm"),
247
+ ).rejects.toThrow("Permission denied accessing directory");
248
+ });
249
+
250
+ it("throws generic error for unknown stat error", async () => {
251
+ mockStat.mockRejectedValueOnce(
252
+ Object.assign(new Error("Disk failure"), { code: "EIO" }),
253
+ );
254
+ await expect(
255
+ validateDirectoryRule("/project", { name: "broken" }, "io"),
256
+ ).rejects.toThrow("Error accessing directory");
257
+ });
258
+ });
@@ -72,7 +72,6 @@ export default async function validateDirectoryRule(
72
72
  ];
73
73
  }
74
74
 
75
- // Hard fail: permission denied, I/O errors, etc. with informative message
76
75
  const errorCode = (e as NodeJS.ErrnoException).code;
77
76
  const errorMessage =
78
77
  errorCode === "EACCES"
@@ -0,0 +1,146 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ afterEach,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ type MockInstance,
12
+ vi,
13
+ } from "vitest";
14
+ import validateFileRule from "./validateFileRule.js";
15
+
16
+ vi.mock("node:fs/promises", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("node:fs/promises")>();
18
+ return { ...actual, readFile: vi.fn().mockImplementation(actual.readFile) };
19
+ });
20
+
21
+ const mockReadFile = readFile as unknown as MockInstance;
22
+
23
+ describe("validateFileRule", () => {
24
+ let tmp: string;
25
+
26
+ beforeEach(() => {
27
+ tmp = join(tmpdir(), `webarchitect-file-${Date.now()}`);
28
+ mkdirSync(tmp, { recursive: true });
29
+ });
30
+
31
+ afterEach(() => {
32
+ rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ it("passes when file matches schema", async () => {
36
+ writeFileSync(
37
+ join(tmp, "package.json"),
38
+ JSON.stringify({ name: "test", version: "1.0.0" }),
39
+ );
40
+ const results = await validateFileRule(
41
+ tmp,
42
+ {
43
+ name: "package.json",
44
+ contains: {
45
+ type: "object",
46
+ required: ["name"],
47
+ properties: { name: { type: "string" } },
48
+ },
49
+ },
50
+ "pkg",
51
+ );
52
+ expect(results).toHaveLength(1);
53
+ expect(results[0].passed).toBe(true);
54
+ });
55
+
56
+ it("fails when file content violates schema", async () => {
57
+ writeFileSync(join(tmp, "config.json"), JSON.stringify({ name: 42 }));
58
+ const results = await validateFileRule(
59
+ tmp,
60
+ {
61
+ name: "config.json",
62
+ contains: {
63
+ type: "object",
64
+ properties: { name: { type: "string" } },
65
+ },
66
+ },
67
+ "cfg",
68
+ );
69
+ expect(results).toHaveLength(1);
70
+ expect(results[0].passed).toBe(false);
71
+ expect(results[0].message).toContain("Validation failed");
72
+ });
73
+
74
+ it("soft-fails when file not found", async () => {
75
+ const results = await validateFileRule(
76
+ tmp,
77
+ { name: "missing.json", contains: { type: "object" } },
78
+ "missing",
79
+ );
80
+ expect(results).toHaveLength(1);
81
+ expect(results[0].passed).toBe(false);
82
+ expect(results[0].message).toContain("File not found");
83
+ });
84
+
85
+ it("throws on invalid JSON", async () => {
86
+ writeFileSync(join(tmp, "bad.json"), "not json{");
87
+ await expect(
88
+ validateFileRule(
89
+ tmp,
90
+ { name: "bad.json", contains: { type: "object" } },
91
+ "bad",
92
+ ),
93
+ ).rejects.toThrow("Invalid JSON");
94
+ });
95
+
96
+ it("throws on directory instead of file", async () => {
97
+ mkdirSync(join(tmp, "adir"));
98
+ await expect(
99
+ validateFileRule(
100
+ tmp,
101
+ { name: "adir", contains: { type: "object" } },
102
+ "dir-as-file",
103
+ ),
104
+ ).rejects.toThrow("Expected file but found directory");
105
+ });
106
+
107
+ it("includes context in result", async () => {
108
+ writeFileSync(join(tmp, "data.json"), JSON.stringify({ ok: true }));
109
+ const results = await validateFileRule(
110
+ tmp,
111
+ { name: "data.json", contains: { type: "object" } },
112
+ "data",
113
+ );
114
+ expect(results[0].context?.type).toBe("file");
115
+ expect(results[0].context?.target).toBe(join(tmp, "data.json"));
116
+ expect(results[0].context?.value).toEqual({ ok: true });
117
+ });
118
+ });
119
+
120
+ describe("validateFileRule error paths", () => {
121
+ it("throws permission denied for EACCES", async () => {
122
+ mockReadFile.mockRejectedValueOnce(
123
+ Object.assign(new Error("EACCES"), { code: "EACCES" }),
124
+ );
125
+ await expect(
126
+ validateFileRule(
127
+ "/project",
128
+ { name: "secret.json", contains: { type: "object" } },
129
+ "perm",
130
+ ),
131
+ ).rejects.toThrow("Permission denied accessing");
132
+ });
133
+
134
+ it("throws generic error for unknown error code", async () => {
135
+ mockReadFile.mockRejectedValueOnce(
136
+ Object.assign(new Error("Disk failure"), { code: "EIO" }),
137
+ );
138
+ await expect(
139
+ validateFileRule(
140
+ "/project",
141
+ { name: "broken.json", contains: { type: "object" } },
142
+ "io",
143
+ ),
144
+ ).rejects.toThrow("Error reading file");
145
+ });
146
+ });
@@ -91,7 +91,6 @@ export default async function validateFileRule(
91
91
  ];
92
92
  }
93
93
 
94
- // Hard fail: permission denied, I/O errors, etc. with informative message
95
94
  const errorCode = (readError as NodeJS.ErrnoException).code;
96
95
  const errorMessage =
97
96
  errorCode === "EACCES"
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import validateRule from "./validateRule.js";
6
+
7
+ describe("validateRule", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = join(tmpdir(), `webarchitect-rule-${Date.now()}`);
12
+ mkdirSync(tmp, { recursive: true });
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tmp, { recursive: true, force: true });
17
+ });
18
+
19
+ it("dispatches file rules to validateFileRule", async () => {
20
+ writeFileSync(join(tmp, "test.json"), JSON.stringify({ ok: true }));
21
+ const results = await validateRule(
22
+ tmp,
23
+ { file: { name: "test.json", contains: { type: "object" } } },
24
+ "file-rule",
25
+ );
26
+ expect(results).toHaveLength(1);
27
+ expect(results[0].passed).toBe(true);
28
+ });
29
+
30
+ it("dispatches directory rules to validateDirectoryRule", async () => {
31
+ mkdirSync(join(tmp, "src"));
32
+ const results = await validateRule(
33
+ tmp,
34
+ { directory: { name: "src" } },
35
+ "dir-rule",
36
+ );
37
+ expect(results).toHaveLength(1);
38
+ expect(results[0].rule).toBe("dir-rule");
39
+ expect(results[0].passed).toBe(true);
40
+ });
41
+
42
+ it("throws for invalid rule type", async () => {
43
+ await expect(validateRule(tmp, {} as any, "bad-rule")).rejects.toThrow(
44
+ "Invalid rule type",
45
+ );
46
+ });
47
+ });
@@ -0,0 +1,79 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import validate from "./validate.js";
6
+
7
+ describe("validate", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = join(tmpdir(), `webarchitect-validate-${Date.now()}`);
12
+ mkdirSync(tmp, { recursive: true });
13
+ vi.spyOn(console, "log").mockImplementation(() => {});
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(tmp, { recursive: true, force: true });
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("validates a project against a schema", async () => {
22
+ // Create a minimal project matching the base schema
23
+ writeFileSync(
24
+ join(tmp, "package.json"),
25
+ JSON.stringify({
26
+ name: "@canonical/test",
27
+ version: "1.0.0",
28
+ type: "module",
29
+ license: "LGPL-3.0",
30
+ scripts: {},
31
+ author: { name: "Test", email: "test@test.com" },
32
+ repository: { type: "git", url: "https://github.com/test/test" },
33
+ }),
34
+ );
35
+
36
+ // Create a minimal schema that checks for package.json
37
+ const schema = {
38
+ name: "test-schema",
39
+ "pkg-exists": {
40
+ file: {
41
+ name: "package.json",
42
+ contains: {
43
+ type: "object",
44
+ required: ["name"],
45
+ properties: { name: { type: "string" } },
46
+ },
47
+ },
48
+ },
49
+ };
50
+ const schemaPath = join(tmp, "test.ruleset.json");
51
+ writeFileSync(schemaPath, JSON.stringify(schema));
52
+
53
+ const results = await validate(tmp, schemaPath);
54
+ expect(results).toHaveLength(1);
55
+ expect(results[0].rule).toBe("pkg-exists");
56
+ expect(results[0].passed).toBe(true);
57
+ });
58
+
59
+ it("returns failure results for invalid project", async () => {
60
+ // Empty project with no package.json
61
+ const schema = {
62
+ name: "test-schema",
63
+ "pkg-check": {
64
+ file: {
65
+ name: "package.json",
66
+ contains: { type: "object" },
67
+ },
68
+ },
69
+ };
70
+ const schemaPath = join(tmp, "test.ruleset.json");
71
+ writeFileSync(schemaPath, JSON.stringify(schema));
72
+
73
+ const results = await validate(tmp, schemaPath);
74
+ expect(results).toHaveLength(1);
75
+ expect(results[0].rule).toBe("pkg-check");
76
+ expect(results[0].passed).toBe(false);
77
+ expect(results[0].message).toContain("File not found");
78
+ });
79
+ });