@intentius/chant-lexicon-aws 0.0.6 → 0.0.9

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 (128) hide show
  1. package/dist/integrity.json +25 -10
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +9444 -4597
  4. package/dist/rules/cf-refs.ts +99 -0
  5. package/dist/rules/ext001.ts +32 -25
  6. package/dist/rules/hardcoded-region.ts +1 -0
  7. package/dist/rules/iam-wildcard.ts +1 -0
  8. package/dist/rules/s3-encryption.ts +3 -3
  9. package/dist/rules/waw016.ts +86 -0
  10. package/dist/rules/waw017.ts +53 -0
  11. package/dist/rules/waw018.ts +71 -0
  12. package/dist/rules/waw019.ts +82 -0
  13. package/dist/rules/waw020.ts +64 -0
  14. package/dist/rules/waw021.ts +53 -0
  15. package/dist/rules/waw022.ts +43 -0
  16. package/dist/rules/waw023.ts +47 -0
  17. package/dist/rules/waw024.ts +54 -0
  18. package/dist/rules/waw025.ts +43 -0
  19. package/dist/rules/waw026.ts +46 -0
  20. package/dist/rules/waw027.ts +50 -0
  21. package/dist/rules/waw028.ts +47 -0
  22. package/dist/rules/waw029.ts +62 -0
  23. package/dist/rules/waw030.ts +246 -0
  24. package/dist/skills/chant-aws.md +430 -0
  25. package/dist/types/index.d.ts +58525 -58501
  26. package/package.json +2 -2
  27. package/src/actions/actions.test.ts +75 -0
  28. package/src/actions/dynamodb.ts +36 -0
  29. package/src/actions/ecr.ts +9 -0
  30. package/src/actions/ecs.ts +5 -0
  31. package/src/actions/iam.ts +3 -0
  32. package/src/actions/index.ts +9 -0
  33. package/src/actions/lambda.ts +11 -0
  34. package/src/actions/logs.ts +4 -0
  35. package/src/actions/s3.ts +34 -0
  36. package/src/actions/sns.ts +5 -0
  37. package/src/actions/sqs.ts +15 -0
  38. package/src/codegen/__snapshots__/snapshot.test.ts.snap +20 -20
  39. package/src/codegen/docs-links.test.ts +143 -0
  40. package/src/codegen/docs.ts +294 -124
  41. package/src/codegen/generate-lexicon.ts +8 -0
  42. package/src/codegen/generate-typescript.ts +25 -1
  43. package/src/codegen/generate.ts +1 -13
  44. package/src/codegen/package.ts +2 -0
  45. package/src/codegen/typecheck.test.ts +1 -1
  46. package/src/composites/composites.test.ts +442 -0
  47. package/src/composites/fargate-alb.ts +253 -0
  48. package/src/composites/index.ts +20 -0
  49. package/src/composites/lambda-api.ts +20 -0
  50. package/src/composites/lambda-dynamodb.ts +64 -0
  51. package/src/composites/lambda-eventbridge.ts +36 -0
  52. package/src/composites/lambda-function.ts +76 -0
  53. package/src/composites/lambda-s3.ts +72 -0
  54. package/src/composites/lambda-sns.ts +30 -0
  55. package/src/composites/lambda-sqs.ts +44 -0
  56. package/src/composites/scheduled-lambda.ts +37 -0
  57. package/src/composites/vpc-default.ts +148 -0
  58. package/src/default-tags.test.ts +38 -0
  59. package/src/default-tags.ts +77 -0
  60. package/src/generated/index.d.ts +58525 -58501
  61. package/src/generated/index.ts +1351 -1351
  62. package/src/generated/lexicon-aws.json +9444 -4597
  63. package/src/import/generator.test.ts +5 -5
  64. package/src/import/generator.ts +4 -4
  65. package/src/import/roundtrip-fixtures.test.ts +2 -1
  66. package/src/import/roundtrip.test.ts +5 -5
  67. package/src/index.ts +21 -0
  68. package/src/integration.test.ts +92 -21
  69. package/src/intrinsics.ts +24 -13
  70. package/src/lint/post-synth/cf-refs.ts +99 -0
  71. package/src/lint/post-synth/ext001.test.ts +214 -31
  72. package/src/lint/post-synth/ext001.ts +32 -25
  73. package/src/lint/post-synth/waw013.test.ts +120 -0
  74. package/src/lint/post-synth/waw014.test.ts +121 -0
  75. package/src/lint/post-synth/waw015.test.ts +147 -0
  76. package/src/lint/post-synth/waw016.test.ts +141 -0
  77. package/src/lint/post-synth/waw016.ts +86 -0
  78. package/src/lint/post-synth/waw017.test.ts +130 -0
  79. package/src/lint/post-synth/waw017.ts +53 -0
  80. package/src/lint/post-synth/waw018.test.ts +109 -0
  81. package/src/lint/post-synth/waw018.ts +71 -0
  82. package/src/lint/post-synth/waw019.test.ts +138 -0
  83. package/src/lint/post-synth/waw019.ts +82 -0
  84. package/src/lint/post-synth/waw020.test.ts +125 -0
  85. package/src/lint/post-synth/waw020.ts +64 -0
  86. package/src/lint/post-synth/waw021.test.ts +81 -0
  87. package/src/lint/post-synth/waw021.ts +53 -0
  88. package/src/lint/post-synth/waw022.test.ts +54 -0
  89. package/src/lint/post-synth/waw022.ts +43 -0
  90. package/src/lint/post-synth/waw023.test.ts +53 -0
  91. package/src/lint/post-synth/waw023.ts +47 -0
  92. package/src/lint/post-synth/waw024.test.ts +64 -0
  93. package/src/lint/post-synth/waw024.ts +54 -0
  94. package/src/lint/post-synth/waw025.test.ts +42 -0
  95. package/src/lint/post-synth/waw025.ts +43 -0
  96. package/src/lint/post-synth/waw026.test.ts +54 -0
  97. package/src/lint/post-synth/waw026.ts +46 -0
  98. package/src/lint/post-synth/waw027.test.ts +63 -0
  99. package/src/lint/post-synth/waw027.ts +50 -0
  100. package/src/lint/post-synth/waw028.test.ts +68 -0
  101. package/src/lint/post-synth/waw028.ts +47 -0
  102. package/src/lint/post-synth/waw029.test.ts +179 -0
  103. package/src/lint/post-synth/waw029.ts +62 -0
  104. package/src/lint/post-synth/waw030.test.ts +800 -0
  105. package/src/lint/post-synth/waw030.ts +246 -0
  106. package/src/lint/rules/hardcoded-region.ts +1 -0
  107. package/src/lint/rules/iam-wildcard.ts +1 -0
  108. package/src/lint/rules/rules.test.ts +8 -8
  109. package/src/lint/rules/s3-encryption.ts +3 -3
  110. package/src/lsp/completions.ts +2 -0
  111. package/src/lsp/hover.ts +17 -0
  112. package/src/nested-stack-integration.test.ts +100 -0
  113. package/src/nested-stack.ts +2 -2
  114. package/src/plugin.test.ts +13 -15
  115. package/src/plugin.ts +552 -114
  116. package/src/serializer.test.ts +370 -43
  117. package/src/serializer.ts +69 -17
  118. package/src/spec/fetch.ts +10 -0
  119. package/src/spec/parse.test.ts +141 -0
  120. package/src/spec/parse.ts +40 -0
  121. package/src/taggable.ts +44 -0
  122. package/src/testdata/nested-stacks/app.ts +26 -0
  123. package/src/testdata/nested-stacks/network/outputs.ts +17 -0
  124. package/src/testdata/nested-stacks/network/security.ts +17 -0
  125. package/src/testdata/nested-stacks/network/vpc.ts +54 -0
  126. package/dist/skills/aws-cloudformation.md +0 -41
  127. package/src/codegen/rollback.test.ts +0 -80
  128. package/src/codegen/rollback.ts +0 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.0.6",
3
+ "version": "0.0.9",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": ["src/", "dist/"],
@@ -22,7 +22,7 @@
22
22
  "prepack": "bun run bundle && bun run validate"
23
23
  },
24
24
  "dependencies": {
25
- "@intentius/chant": "0.0.5",
25
+ "@intentius/chant": "0.0.9",
26
26
  "fflate": "^0.8.2",
27
27
  "js-yaml": "^4.1.0"
28
28
  },
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { S3Actions } from "./s3";
3
+ import { LambdaActions } from "./lambda";
4
+ import { DynamoDBActions } from "./dynamodb";
5
+ import { SQSActions } from "./sqs";
6
+ import { SNSActions } from "./sns";
7
+ import { IAMActions } from "./iam";
8
+ import { ECRActions } from "./ecr";
9
+ import { LogsActions } from "./logs";
10
+ import { ECSActions } from "./ecs";
11
+
12
+ const allConstants = {
13
+ S3Actions,
14
+ LambdaActions,
15
+ DynamoDBActions,
16
+ SQSActions,
17
+ SNSActions,
18
+ IAMActions,
19
+ ECRActions,
20
+ LogsActions,
21
+ ECSActions,
22
+ };
23
+
24
+ describe("Action Constants", () => {
25
+ for (const [name, constant] of Object.entries(allConstants)) {
26
+ describe(name, () => {
27
+ test("every action string matches serviceName:actionName pattern", () => {
28
+ for (const [group, actions] of Object.entries(constant)) {
29
+ for (const action of actions) {
30
+ expect(action).toMatch(
31
+ /^[a-z][a-z0-9]*:[A-Z*][A-Za-z0-9*]*$/,
32
+ );
33
+ }
34
+ }
35
+ });
36
+
37
+ test("no duplicate actions within a group", () => {
38
+ for (const [group, actions] of Object.entries(constant)) {
39
+ const unique = new Set(actions);
40
+ expect(unique.size).toBe(
41
+ actions.length,
42
+ );
43
+ }
44
+ });
45
+ });
46
+ }
47
+
48
+ describe("S3Actions broad groups are supersets", () => {
49
+ test("ReadWrite contains all ReadOnly actions", () => {
50
+ for (const action of S3Actions.ReadOnly) {
51
+ expect(S3Actions.ReadWrite).toContain(action);
52
+ }
53
+ });
54
+
55
+ test("ReadWrite contains all WriteOnly actions", () => {
56
+ for (const action of S3Actions.WriteOnly) {
57
+ expect(S3Actions.ReadWrite).toContain(action);
58
+ }
59
+ });
60
+ });
61
+
62
+ describe("DynamoDBActions broad groups are supersets", () => {
63
+ test("ReadWrite contains all ReadOnly actions", () => {
64
+ for (const action of DynamoDBActions.ReadOnly) {
65
+ expect(DynamoDBActions.ReadWrite).toContain(action);
66
+ }
67
+ });
68
+
69
+ test("ReadWrite contains all WriteOnly actions", () => {
70
+ for (const action of DynamoDBActions.WriteOnly) {
71
+ expect(DynamoDBActions.ReadWrite).toContain(action);
72
+ }
73
+ });
74
+ });
75
+ });
@@ -0,0 +1,36 @@
1
+ export const DynamoDBActions = {
2
+ // Broad groups
3
+ ReadOnly: [
4
+ "dynamodb:GetItem",
5
+ "dynamodb:BatchGetItem",
6
+ "dynamodb:Query",
7
+ "dynamodb:Scan",
8
+ "dynamodb:DescribeTable",
9
+ "dynamodb:ConditionCheckItem",
10
+ ],
11
+ WriteOnly: [
12
+ "dynamodb:PutItem",
13
+ "dynamodb:UpdateItem",
14
+ "dynamodb:DeleteItem",
15
+ "dynamodb:BatchWriteItem",
16
+ ],
17
+ ReadWrite: [
18
+ "dynamodb:GetItem",
19
+ "dynamodb:BatchGetItem",
20
+ "dynamodb:Query",
21
+ "dynamodb:Scan",
22
+ "dynamodb:DescribeTable",
23
+ "dynamodb:ConditionCheckItem",
24
+ "dynamodb:PutItem",
25
+ "dynamodb:UpdateItem",
26
+ "dynamodb:DeleteItem",
27
+ "dynamodb:BatchWriteItem",
28
+ ],
29
+ Full: ["dynamodb:*"],
30
+
31
+ // Operation-specific
32
+ GetItem: ["dynamodb:GetItem", "dynamodb:BatchGetItem"],
33
+ PutItem: ["dynamodb:PutItem", "dynamodb:BatchWriteItem"],
34
+ Query: ["dynamodb:Query"],
35
+ Scan: ["dynamodb:Scan"],
36
+ } as const;
@@ -0,0 +1,9 @@
1
+ export const ECRActions = {
2
+ Pull: [
3
+ "ecr:GetAuthorizationToken",
4
+ "ecr:BatchCheckLayerAvailability",
5
+ "ecr:GetDownloadUrlForLayer",
6
+ "ecr:BatchGetImage",
7
+ ],
8
+ Full: ["ecr:*"],
9
+ } as const;
@@ -0,0 +1,5 @@
1
+ export const ECSActions = {
2
+ RunTask: ["ecs:RunTask", "ecs:StopTask", "ecs:DescribeTasks"],
3
+ Service: ["ecs:CreateService", "ecs:UpdateService", "ecs:DeleteService", "ecs:DescribeServices"],
4
+ Full: ["ecs:*"],
5
+ } as const;
@@ -0,0 +1,3 @@
1
+ export const IAMActions = {
2
+ PassRole: ["iam:PassRole"],
3
+ } as const;
@@ -0,0 +1,9 @@
1
+ export { S3Actions } from "./s3";
2
+ export { LambdaActions } from "./lambda";
3
+ export { DynamoDBActions } from "./dynamodb";
4
+ export { SQSActions } from "./sqs";
5
+ export { SNSActions } from "./sns";
6
+ export { IAMActions } from "./iam";
7
+ export { ECRActions } from "./ecr";
8
+ export { LogsActions } from "./logs";
9
+ export { ECSActions } from "./ecs";
@@ -0,0 +1,11 @@
1
+ export const LambdaActions = {
2
+ Invoke: ["lambda:InvokeFunction", "lambda:InvokeAsync"],
3
+ ReadOnly: [
4
+ "lambda:GetFunction",
5
+ "lambda:GetFunctionConfiguration",
6
+ "lambda:GetPolicy",
7
+ "lambda:ListVersionsByFunction",
8
+ "lambda:ListAliases",
9
+ ],
10
+ Full: ["lambda:*"],
11
+ } as const;
@@ -0,0 +1,4 @@
1
+ export const LogsActions = {
2
+ Write: ["logs:CreateLogStream", "logs:PutLogEvents"],
3
+ Full: ["logs:*"],
4
+ } as const;
@@ -0,0 +1,34 @@
1
+ export const S3Actions = {
2
+ // Broad groups (from AWS managed policies)
3
+ ReadOnly: [
4
+ "s3:GetObject",
5
+ "s3:GetObjectVersion",
6
+ "s3:GetBucketLocation",
7
+ "s3:ListBucket",
8
+ "s3:ListBucketVersions",
9
+ ],
10
+ WriteOnly: [
11
+ "s3:PutObject",
12
+ "s3:DeleteObject",
13
+ "s3:PutObjectAcl",
14
+ "s3:AbortMultipartUpload",
15
+ ],
16
+ ReadWrite: [
17
+ "s3:GetObject",
18
+ "s3:GetObjectVersion",
19
+ "s3:GetBucketLocation",
20
+ "s3:ListBucket",
21
+ "s3:ListBucketVersions",
22
+ "s3:PutObject",
23
+ "s3:DeleteObject",
24
+ "s3:PutObjectAcl",
25
+ "s3:AbortMultipartUpload",
26
+ ],
27
+ Full: ["s3:*"],
28
+
29
+ // Operation-specific
30
+ GetObject: ["s3:GetObject", "s3:GetObjectVersion"],
31
+ PutObject: ["s3:PutObject", "s3:AbortMultipartUpload"],
32
+ DeleteObject: ["s3:DeleteObject", "s3:DeleteObjectVersion"],
33
+ ListObjects: ["s3:ListBucket", "s3:ListBucketVersions"],
34
+ } as const;
@@ -0,0 +1,5 @@
1
+ export const SNSActions = {
2
+ Publish: ["sns:Publish"],
3
+ Subscribe: ["sns:Subscribe", "sns:Unsubscribe"],
4
+ Full: ["sns:*"],
5
+ } as const;
@@ -0,0 +1,15 @@
1
+ export const SQSActions = {
2
+ SendMessage: [
3
+ "sqs:SendMessage",
4
+ "sqs:GetQueueUrl",
5
+ "sqs:GetQueueAttributes",
6
+ ],
7
+ ReceiveMessage: [
8
+ "sqs:ReceiveMessage",
9
+ "sqs:DeleteMessage",
10
+ "sqs:ChangeMessageVisibility",
11
+ "sqs:GetQueueUrl",
12
+ "sqs:GetQueueAttributes",
13
+ ],
14
+ Full: ["sqs:*"],
15
+ } as const;
@@ -3,7 +3,7 @@
3
3
  exports[`snapshot tests Bucket lexicon entry 1`] = `
4
4
  {
5
5
  "attrs": {
6
- "arn": "Arn",
6
+ "Arn": "Arn",
7
7
  },
8
8
  "createOnly": [
9
9
  "BucketName",
@@ -27,7 +27,7 @@ exports[`snapshot tests Bucket lexicon entry 1`] = `
27
27
  exports[`snapshot tests Function lexicon entry 1`] = `
28
28
  {
29
29
  "attrs": {
30
- "arn": "Arn",
30
+ "Arn": "Arn",
31
31
  },
32
32
  "kind": "resource",
33
33
  "lexicon": "aws",
@@ -79,8 +79,8 @@ exports[`snapshot tests Function lexicon entry 1`] = `
79
79
  exports[`snapshot tests Role lexicon entry 1`] = `
80
80
  {
81
81
  "attrs": {
82
- "arn": "Arn",
83
- "roleId": "RoleId",
82
+ "Arn": "Arn",
83
+ "RoleId": "RoleId",
84
84
  },
85
85
  "kind": "resource",
86
86
  "lexicon": "aws",
@@ -160,17 +160,17 @@ exports[`snapshot tests Bucket .d.ts class declaration 1`] = `
160
160
  "export declare class Bucket {
161
161
  constructor(props: {
162
162
  /** A name for the bucket. */
163
- bucketName: string;
163
+ BucketName: string;
164
164
  /** The Amazon Resource Name (ARN) of the bucket. */
165
- arn?: string;
165
+ Arn?: string;
166
166
  /** Specifies default encryption for a bucket. */
167
- bucketEncryption?: Bucket_BucketEncryption;
167
+ BucketEncryption?: Bucket_BucketEncryption;
168
168
  /** An arbitrary set of tags (key-value pairs) for this S3 bucket. */
169
- tags?: Bucket_Tag[];
169
+ Tags?: Bucket_Tag[];
170
170
  /** Enables multiple versions of all objects in this bucket. */
171
- versioningConfiguration?: Bucket_VersioningConfiguration;
172
- });
173
- readonly arn: string;
171
+ VersioningConfiguration?: Bucket_VersioningConfiguration;
172
+ }, attributes?: CFResourceAttributes);
173
+ readonly Arn: string;
174
174
  }"
175
175
  `;
176
176
 
@@ -178,20 +178,20 @@ exports[`snapshot tests Function .d.ts class declaration 1`] = `
178
178
  "export declare class Function {
179
179
  constructor(props: {
180
180
  /** The code for the function. */
181
- code: Function_Code;
181
+ Code: Function_Code;
182
182
  /** The ARN of the function's execution role. */
183
- role: string;
183
+ Role: string;
184
184
  /** The ARN of the function. */
185
- arn?: string;
185
+ Arn?: string;
186
186
  /** The name of the Lambda function. */
187
- functionName?: string;
187
+ FunctionName?: string;
188
188
  /** The name of the method within your code that Lambda calls to run your function. */
189
- handler?: string;
189
+ Handler?: string;
190
190
  /** The amount of memory available to the function at runtime. */
191
- memorySize?: number;
191
+ MemorySize?: number;
192
192
  /** The identifier of the function's runtime. */
193
- runtime?: "java17" | "java21" | "nodejs18.x" | "nodejs20.x" | "python3.11" | "python3.12";
194
- });
195
- readonly arn: string;
193
+ Runtime?: "java17" | "java21" | "nodejs18.x" | "nodejs20.x" | "python3.11" | "python3.12";
194
+ }, attributes?: CFResourceAttributes);
195
+ readonly Arn: string;
196
196
  }"
197
197
  `;
@@ -0,0 +1,143 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { existsSync, readdirSync, readFileSync } from "fs";
3
+ import { join, basename } from "path";
4
+
5
+ const docsDir = join(import.meta.dir, "..", "..", "docs", "src", "content", "docs");
6
+ const docsSource = join(import.meta.dir, "docs.ts");
7
+ const docsExist = existsSync(docsDir);
8
+
9
+ /**
10
+ * Collect all page slugs from the generated docs directory.
11
+ */
12
+ function getPageSlugs(): Set<string> {
13
+ if (!docsExist) return new Set();
14
+ const slugs = new Set<string>();
15
+ for (const file of readdirSync(docsDir)) {
16
+ if (file.endsWith(".mdx")) {
17
+ slugs.add(basename(file, ".mdx"));
18
+ }
19
+ }
20
+ return slugs;
21
+ }
22
+
23
+ /**
24
+ * Extract markdown links: [text](href)
25
+ */
26
+ function extractMarkdownLinks(content: string): Array<{ text: string; href: string; line: number }> {
27
+ const links: Array<{ text: string; href: string; line: number }> = [];
28
+ const lines = content.split("\n");
29
+ for (let i = 0; i < lines.length; i++) {
30
+ const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
31
+ let match;
32
+ while ((match = regex.exec(lines[i])) !== null) {
33
+ const href = match[2];
34
+ if (href.startsWith("http://") || href.startsWith("https://")) continue;
35
+ if (href.startsWith("#")) continue;
36
+ links.push({ text: match[1], href, line: i + 1 });
37
+ }
38
+ }
39
+ return links;
40
+ }
41
+
42
+ /**
43
+ * Check if a relative link target exists as a page slug.
44
+ */
45
+ function resolveTarget(href: string, slugs: Set<string>): string | null {
46
+ const pathPart = href.split("#")[0].replace(/\/$/, "");
47
+ if (!pathPart) return null;
48
+
49
+ let target: string | undefined;
50
+ if (pathPart.startsWith("./")) target = pathPart.slice(2);
51
+ else if (pathPart.startsWith("../")) target = pathPart.slice(3);
52
+ else if (pathPart.startsWith("/chant/lexicons/aws/")) {
53
+ target = pathPart.replace("/chant/lexicons/aws/", "").replace(/\/$/, "") || "index";
54
+ } else if (!pathPart.includes("/") && !pathPart.startsWith(".")) {
55
+ target = pathPart;
56
+ }
57
+
58
+ if (target === undefined) return null;
59
+ return slugs.has(target) ? null : `target page "${target}" does not exist`;
60
+ }
61
+
62
+ describe("docs internal links", () => {
63
+ const slugs = getPageSlugs();
64
+
65
+ test("page slugs are discovered", () => {
66
+ if (!docsExist) return; // generated docs not present (e.g. CI)
67
+ expect(slugs.size).toBeGreaterThan(5);
68
+ expect(slugs.has("composites")).toBe(true);
69
+ expect(slugs.has("nested-stacks")).toBe(true);
70
+ expect(slugs.has("index")).toBe(true);
71
+ });
72
+
73
+ // Validate generated MDX files (skip if docs not generated)
74
+ for (const file of (docsExist ? readdirSync(docsDir) : [])) {
75
+ if (!file.endsWith(".mdx")) continue;
76
+ const slug = basename(file, ".mdx");
77
+
78
+ test(`${slug}.mdx — internal links resolve to existing pages`, () => {
79
+ const content = readFileSync(join(docsDir, file), "utf-8");
80
+ const links = extractMarkdownLinks(content);
81
+ const errors: string[] = [];
82
+ for (const link of links) {
83
+ const error = resolveTarget(link.href, slugs);
84
+ if (error) errors.push(`line ${link.line}: [${link.text}](${link.href}) — ${error}`);
85
+ }
86
+ if (errors.length > 0) {
87
+ throw new Error(`Broken links in ${file}:\n${errors.join("\n")}`);
88
+ }
89
+ });
90
+
91
+ test(`${slug}.mdx — non-index pages use ../ not ./ for cross-page links`, () => {
92
+ if (slug === "index") return;
93
+ const content = readFileSync(join(docsDir, file), "utf-8");
94
+ const links = extractMarkdownLinks(content);
95
+ const errors: string[] = [];
96
+ for (const link of links) {
97
+ const pathPart = link.href.split("#")[0];
98
+ if (pathPart.startsWith("./")) {
99
+ const target = pathPart.slice(2).replace(/\/$/, "");
100
+ if (slugs.has(target)) {
101
+ errors.push(`line ${link.line}: [${link.text}](${link.href}) — use "../${target}/" instead`);
102
+ }
103
+ }
104
+ }
105
+ if (errors.length > 0) {
106
+ throw new Error(`Broken ./ links in non-index page ${file}:\n${errors.join("\n")}`);
107
+ }
108
+ });
109
+ }
110
+
111
+ // Validate source docs.ts — catches broken links before regeneration
112
+ test("docs.ts source — cross-page links use ../ not ./", () => {
113
+ if (!docsExist) return; // needs generated slugs for validation
114
+ const content = readFileSync(docsSource, "utf-8");
115
+ const links = extractMarkdownLinks(content);
116
+ const errors: string[] = [];
117
+ for (const link of links) {
118
+ const pathPart = link.href.split("#")[0];
119
+ // Links in docs.ts extraPages are rendered on non-index pages,
120
+ // so they must use ../ to navigate to sibling pages
121
+ if (pathPart.startsWith("./") && slugs.has(pathPart.slice(2).replace(/\/$/, ""))) {
122
+ errors.push(`line ${link.line}: [${link.text}](${link.href}) — use "../" prefix for cross-page links`);
123
+ }
124
+ }
125
+ if (errors.length > 0) {
126
+ throw new Error(`docs.ts has ./ links that will break on non-index pages:\n${errors.join("\n")}`);
127
+ }
128
+ });
129
+
130
+ test("docs.ts source — link targets exist as pages", () => {
131
+ if (!docsExist) return; // needs generated slugs for validation
132
+ const content = readFileSync(docsSource, "utf-8");
133
+ const links = extractMarkdownLinks(content);
134
+ const errors: string[] = [];
135
+ for (const link of links) {
136
+ const error = resolveTarget(link.href, slugs);
137
+ if (error) errors.push(`line ${link.line}: [${link.text}](${link.href}) — ${error}`);
138
+ }
139
+ if (errors.length > 0) {
140
+ throw new Error(`docs.ts has links to non-existent pages:\n${errors.join("\n")}`);
141
+ }
142
+ });
143
+ });