@figulus/validator-core 0.5.0-alpha-dev-01

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 ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@figulus/validator-core",
3
+ "version": "0.5.0-alpha-dev-01",
4
+ "description": "Zod schemas for the Figulus engine API",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "sideEffects": false,
11
+ "scripts": {
12
+ "build": "tsc --project tsconfig.build.json",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@figulus/schema": "^0.5.0-alpha-dev-8",
17
+ "strip-json-comments": "^5.0.1",
18
+ "zod": "^4.3.6"
19
+ },
20
+ "devDependencies": {
21
+ "prettier": "^3.8.2",
22
+ "typescript": "^5.7.2"
23
+ }
24
+ }
@@ -0,0 +1,191 @@
1
+ import z from "zod";
2
+ import { parseJSON } from "./lib/parse.js";
3
+ import { validateBlob } from "./lib/validate-file/blob.js";
4
+ import { validateEntity } from "./lib/validate-file/entity.js";
5
+ import { validateNamespaceMetadata } from "./lib/validate-file/namespace-metadata.js";
6
+ import { validateNamespaceOverrides } from "./lib/validate-file/namespace-overrides.js";
7
+ import { PR } from "./pr.js";
8
+ import {
9
+ figParserMetadataSchema,
10
+ figSpecMetadataSchema,
11
+ figStackMetadataSchema,
12
+ namespaceVerificationsSchema,
13
+ pushLimitOverridesSchema,
14
+ } from "@figulus/schema/registry";
15
+ import { FileValidationResult, SchemaObject } from "./types.js";
16
+ import {
17
+ ERROR_CODES,
18
+ ValidationResult,
19
+ createError,
20
+ } from "./validation-result.js";
21
+
22
+ //
23
+ // Entity metadata files
24
+ //
25
+
26
+ const fileTypeEntitySchema = z.enum(["spec", "stack", "parser"]);
27
+ export type FileTypeEntity = z.infer<typeof fileTypeEntitySchema>;
28
+
29
+ const entityFileMap: {
30
+ [Id in FileTypeEntity]: {
31
+ id: Id;
32
+ metadataSchema: SchemaObject;
33
+ extension: string;
34
+ };
35
+ } = {
36
+ spec: {
37
+ id: "spec",
38
+ metadataSchema: figSpecMetadataSchema,
39
+ extension: "figspec",
40
+ },
41
+ stack: {
42
+ id: "stack",
43
+ metadataSchema: figStackMetadataSchema,
44
+ extension: "figstack",
45
+ },
46
+ parser: {
47
+ id: "parser",
48
+ metadataSchema: figParserMetadataSchema,
49
+ extension: "js",
50
+ },
51
+ };
52
+
53
+ export function getExtensionFromFileTypeEntity(
54
+ entityType: FileTypeEntity,
55
+ ): string {
56
+ return entityFileMap[entityType].extension;
57
+ }
58
+
59
+ export function getSchemaFromFileTypeEntity(
60
+ entityType: FileTypeEntity,
61
+ ): SchemaObject {
62
+ return entityFileMap[entityType].metadataSchema;
63
+ }
64
+
65
+ export function getFileTypeEntityFromExtension(
66
+ extension: string,
67
+ ): FileTypeEntity | null {
68
+ return (
69
+ Object.values(entityFileMap).find((e) => e.extension === extension)?.id ||
70
+ null
71
+ );
72
+ }
73
+
74
+ //
75
+ // Namespace overrides files
76
+ //
77
+
78
+ const fileTypeNamespaceOverridesSchema = z.enum(["verified", "limits"]);
79
+ export type FileTypeNamespaceOverrides = z.infer<
80
+ typeof fileTypeNamespaceOverridesSchema
81
+ >;
82
+
83
+ const namespaceOverridesFileMap: {
84
+ [Id in FileTypeNamespaceOverrides]: {
85
+ schema: SchemaObject;
86
+ };
87
+ } = {
88
+ verified: {
89
+ schema: namespaceVerificationsSchema,
90
+ },
91
+ limits: {
92
+ schema: pushLimitOverridesSchema,
93
+ },
94
+ };
95
+
96
+ export function getSchemaFromFileTypeNamespaceOverrides(
97
+ overridesFileType: FileTypeNamespaceOverrides,
98
+ ): SchemaObject {
99
+ return namespaceOverridesFileMap[overridesFileType].schema;
100
+ }
101
+
102
+ //
103
+ // All file types
104
+ //
105
+
106
+ type FileType =
107
+ | FileTypeEntity
108
+ | "blob"
109
+ | "namespace"
110
+ | FileTypeNamespaceOverrides;
111
+
112
+ //
113
+ // Main ChangedFile class
114
+ //
115
+
116
+ export class ChangedFile {
117
+ constructor(
118
+ public path: string,
119
+ public pr: PR,
120
+ ) {}
121
+
122
+ public getFileType(): FileType | null {
123
+ if (this.path.startsWith("specs/")) return "spec";
124
+ if (this.path.startsWith("stacks/")) return "stack";
125
+ if (this.path.startsWith("parsers/")) return "parser";
126
+ if (this.path.startsWith("blobs/")) return "blob";
127
+ if (this.path.startsWith("namespaces/") && this.path.endsWith(".json")) {
128
+ if (this.path === "namespaces/verified.json") return "verified";
129
+ if (this.path === "namespaces/push-limit-overrides.json") return "limits";
130
+ return "namespace";
131
+ }
132
+ return null;
133
+ }
134
+
135
+ public getNamespace(type: FileTypeEntity | "namespace"): string {
136
+ const base = this.pr.registry.helpers.fs.splitPath(this.path)[1];
137
+ if (type === "namespace") return base.replace(".json", "");
138
+ return base;
139
+ }
140
+
141
+ public parseJson(): any {
142
+ const { settings, helpers } = this.pr.registry;
143
+ const { fs } = helpers;
144
+
145
+ const fullPath = fs.resolvePath(settings.repoRoot, this.path);
146
+ const content = fs.readFileAsUtf8(fullPath);
147
+
148
+ return parseJSON(content);
149
+ }
150
+
151
+ public validate(): FileValidationResult {
152
+ const fileType = this.getFileType();
153
+
154
+ if (!fileType) {
155
+ return {
156
+ file: this.path,
157
+ type: "unknown",
158
+ result: {
159
+ success: false,
160
+ errors: [
161
+ createError(
162
+ "File path does not match any known registry structure",
163
+ ERROR_CODES.FILE_TYPE_UNKNOWN,
164
+ ),
165
+ ],
166
+ },
167
+ };
168
+ }
169
+
170
+ let result: ValidationResult;
171
+ switch (fileType) {
172
+ case "spec":
173
+ case "stack":
174
+ case "parser":
175
+ result = validateEntity(this, fileType);
176
+ break;
177
+ case "blob":
178
+ result = validateBlob(this);
179
+ break;
180
+ case "namespace":
181
+ result = validateNamespaceMetadata(this);
182
+ break;
183
+ case "verified":
184
+ case "limits":
185
+ result = validateNamespaceOverrides(this, fileType);
186
+ break;
187
+ }
188
+
189
+ return { file: this.path, type: fileType, result };
190
+ }
191
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { Helpers, RegistryValidator } from "./registry-validator.js";
2
+ export { settingsSchema } from "./settings.js";
3
+ export type { Settings } from "./settings.js";
@@ -0,0 +1,91 @@
1
+ import { Helpers } from "../registry-validator.js";
2
+ import { getNamespaceEditorEntry } from "./namespace.js";
3
+ import {
4
+ getNamespaceMetadataFromHead,
5
+ getPushLimitOverridesFromHead,
6
+ } from "./git-head.js";
7
+ import {
8
+ ValidationError,
9
+ ERROR_CODES,
10
+ createError,
11
+ } from "../validation-result.js";
12
+ import { Settings } from "../settings.js";
13
+
14
+ export function checkPushLimit(
15
+ prAuthor: string,
16
+ namespace: string,
17
+ helpers: Helpers,
18
+ settings: Settings,
19
+ ): ValidationError | null {
20
+ const namespaceMetadata = getNamespaceMetadataFromHead(namespace, helpers);
21
+ if (!namespaceMetadata) return null;
22
+
23
+ const editorEntry = getNamespaceEditorEntry(prAuthor, namespaceMetadata);
24
+ if (!editorEntry) return null;
25
+
26
+ let editorLimit = editorEntry.pushLimit || {
27
+ unit: settings.pushLimits.default.unit,
28
+ value: settings.pushLimits.default.pushes,
29
+ };
30
+
31
+ const overrides = getPushLimitOverridesFromHead(helpers);
32
+ const override = overrides
33
+ ? overrides.find((o) => o.namespace === namespace)
34
+ : undefined;
35
+
36
+ let effectiveLimit = editorLimit;
37
+ let limitSource = "editor settings";
38
+
39
+ if (override) {
40
+ const overrideValue = override.pushLimit.value;
41
+ const editorValue = editorLimit.value;
42
+
43
+ if (overrideValue < editorValue) {
44
+ effectiveLimit = override.pushLimit;
45
+ limitSource = "namespace override";
46
+ }
47
+ } else if (!editorEntry.pushLimit) {
48
+ limitSource = "default (10/day)";
49
+ }
50
+
51
+ try {
52
+ const now = new Date();
53
+ const msInDay = 24 * 60 * 60 * 1000;
54
+ const msInUnit = effectiveLimit.unit === "daily" ? msInDay : msInDay * 7;
55
+ const startDate = new Date(now.getTime() - msInUnit);
56
+
57
+ const filePrefixes = [
58
+ `specs/${namespace}/`,
59
+ `stacks/${namespace}/`,
60
+ `parsers/${namespace}/`,
61
+ `blobs/${namespace}/`,
62
+ ];
63
+
64
+ const prs = helpers.git
65
+ .getAllPRs()
66
+ .filter(
67
+ (pr) => pr.user.id === prAuthor && new Date(pr.createdAt) >= startDate,
68
+ );
69
+
70
+ let count = 0;
71
+ for (const pr of prs) {
72
+ const files = helpers.git.getPRFiles(pr.url);
73
+
74
+ const touchesNamespace = files.some((f: any) =>
75
+ filePrefixes.some((prefix) => f.filename.startsWith(prefix)),
76
+ );
77
+
78
+ if (touchesNamespace) count++;
79
+ }
80
+
81
+ if (count >= effectiveLimit.value)
82
+ return createError(
83
+ `Push limit exceeded for namespace "${namespace}": ${count}/${effectiveLimit.value} ${effectiveLimit.unit} pushes used. Limit set by ${limitSource}.`,
84
+ ERROR_CODES.PUSH_LIMIT_EXCEEDED,
85
+ );
86
+
87
+ return null;
88
+ } catch (error) {
89
+ return null;
90
+ }
91
+ }
@@ -0,0 +1,45 @@
1
+ import { SchemaObject } from "../types.js";
2
+ import { Helpers } from "../registry-validator.js";
3
+ import {
4
+ NamespaceMetadata,
5
+ namespaceMetadataSchema,
6
+ PushLimitOverrides,
7
+ pushLimitOverridesSchema,
8
+ } from "@figulus/schema/registry";
9
+ import { parseJSON } from "./parse.js";
10
+
11
+ function getFileFromHeadAndParse(
12
+ filePath: string,
13
+ schema: SchemaObject,
14
+ helpers: Helpers,
15
+ ) {
16
+ try {
17
+ const headContent = helpers.git.showHead(filePath);
18
+ const data = parseJSON(headContent);
19
+ const result = schema.safeParse(data);
20
+ return result.success ? data : null;
21
+ } catch (error) {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function getPushLimitOverridesFromHead(
27
+ helpers: Helpers,
28
+ ): PushLimitOverrides | null {
29
+ return getFileFromHeadAndParse(
30
+ "namespaces/push-limit-overrides.json",
31
+ pushLimitOverridesSchema,
32
+ helpers,
33
+ );
34
+ }
35
+
36
+ export function getNamespaceMetadataFromHead(
37
+ namespace: string,
38
+ helpers: Helpers,
39
+ ): NamespaceMetadata | null {
40
+ return getFileFromHeadAndParse(
41
+ `namespaces/${namespace}.json`,
42
+ namespaceMetadataSchema,
43
+ helpers,
44
+ );
45
+ }
@@ -0,0 +1,21 @@
1
+ import { ChangedFile, FileTypeEntity } from "../changed-file.js";
2
+ import {
3
+ NamespaceMetadata,
4
+ NamespaceMetadataEditorEntry,
5
+ } from "@figulus/schema/registry";
6
+
7
+ export function getNamespaceFromFilePath(
8
+ file: ChangedFile,
9
+ type: FileTypeEntity | "namespace",
10
+ ): string {
11
+ const base = file.pr.registry.helpers.fs.splitPath(file.path)[1];
12
+ if (type === "namespace") return base.replace(".json", "");
13
+ return base;
14
+ }
15
+
16
+ export function getNamespaceEditorEntry(
17
+ user: string,
18
+ namespaceMetadata: NamespaceMetadata,
19
+ ): NamespaceMetadataEditorEntry | undefined {
20
+ return namespaceMetadata.editors.find((e) => e.username === user);
21
+ }
@@ -0,0 +1,47 @@
1
+ import stripComments from "strip-json-comments";
2
+ import z from "zod";
3
+ import { ChangedFile } from "../changed-file.js";
4
+ import {
5
+ ValidationResult,
6
+ ERROR_CODES,
7
+ createError,
8
+ success,
9
+ } from "../validation-result.js";
10
+
11
+ export function parseJSON(data: string): any {
12
+ return JSON.parse(stripComments(data));
13
+ }
14
+
15
+ export function parseSchema(
16
+ data: any,
17
+ schema: z.ZodType<any>,
18
+ ): ValidationResult {
19
+ const result = schema.safeParse(data);
20
+ if (!result.success) {
21
+ const errors = result.error.issues.map((err) => {
22
+ const path = err.path.length > 0 ? err.path.join(".") : "root";
23
+ return createError(
24
+ `${path}: ${err.message}`,
25
+ ERROR_CODES.ENTITY_SCHEMA_INVALID,
26
+ );
27
+ });
28
+ return { success: false, errors };
29
+ }
30
+ return success();
31
+ }
32
+
33
+ export function parseFileJson(file: ChangedFile): any {
34
+ try {
35
+ const { settings, helpers } = file.pr.registry;
36
+ const { fs } = helpers;
37
+
38
+ const fullPath = fs.resolvePath(settings.repoRoot, file.path);
39
+ const content = fs.readFileAsUtf8(fullPath);
40
+
41
+ return parseJSON(content);
42
+ } catch (error) {
43
+ throw new Error(
44
+ `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`,
45
+ );
46
+ }
47
+ }
@@ -0,0 +1,17 @@
1
+ import { RegistryValidator } from "../registry-validator.js";
2
+
3
+ export function isRegistryMaintainer(
4
+ user: string,
5
+ registry: RegistryValidator,
6
+ ): boolean {
7
+ const { registryMaintainers } = registry.settings;
8
+ return registryMaintainers.includes(user);
9
+ }
10
+
11
+ export function isReservedNamespace(
12
+ namespace: string,
13
+ registry: RegistryValidator,
14
+ ): boolean {
15
+ const { restrictedNamespaces } = registry.settings;
16
+ return restrictedNamespaces.includes(namespace);
17
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ FileTypeEntity,
3
+ getExtensionFromFileTypeEntity,
4
+ } from "../changed-file.js";
5
+ import { RegistryValidator } from "../registry-validator.js";
6
+ import {
7
+ ValidationResult,
8
+ ERROR_CODES,
9
+ createError,
10
+ success,
11
+ } from "../validation-result.js";
12
+
13
+ export function validateBlobReferences(
14
+ data: any,
15
+ namespace: string,
16
+ type: FileTypeEntity,
17
+ registry: RegistryValidator,
18
+ ): ValidationResult {
19
+ const errors = [];
20
+
21
+ const variants = data.variants || [];
22
+ if (!Array.isArray(variants)) return success();
23
+
24
+ for (const variant of variants) {
25
+ const blob = variant.blob || {};
26
+ const contentHash = blob.contentHash;
27
+ const extension = getExtensionFromFileTypeEntity(type);
28
+
29
+ if (!contentHash || !extension) continue;
30
+
31
+ const blobPath = registry.helpers.fs.resolvePath(
32
+ registry.settings.repoRoot,
33
+ `blobs/${namespace}/${contentHash}.${extension}`,
34
+ );
35
+
36
+ if (!registry.helpers.fs.fileOrDirExists(blobPath)) {
37
+ errors.push(
38
+ createError(
39
+ `Variant references blob ${contentHash} but blobs/${namespace}/${contentHash}.${extension} does not exist in the repository`,
40
+ ERROR_CODES.BLOB_REFERENCE_NOT_FOUND,
41
+ ),
42
+ );
43
+ }
44
+ }
45
+
46
+ return errors.length > 0 ? { success: false, errors } : success();
47
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ ChangedFile,
3
+ getFileTypeEntityFromExtension,
4
+ } from "../../changed-file.js";
5
+ import {
6
+ ValidationResult,
7
+ ERROR_CODES,
8
+ createError,
9
+ success,
10
+ } from "../../validation-result.js";
11
+
12
+ export function validateBlob(file: ChangedFile): ValidationResult {
13
+ const { helpers, settings } = file.pr.registry;
14
+
15
+ const fileName = helpers.fs.splitPath(file.path).pop();
16
+ if (!fileName) {
17
+ return {
18
+ success: false,
19
+ errors: [
20
+ createError("Invalid blob filename", ERROR_CODES.BLOB_INVALID_FILENAME),
21
+ ],
22
+ };
23
+ }
24
+
25
+ const parts = fileName.split(".");
26
+ if (parts.length < 2) {
27
+ return {
28
+ success: false,
29
+ errors: [
30
+ createError(
31
+ "Blob filename must have extension",
32
+ ERROR_CODES.BLOB_MISSING_EXTENSION,
33
+ ),
34
+ ],
35
+ };
36
+ }
37
+
38
+ const expectedHash = parts[0];
39
+ const extension = parts.slice(1).join(".");
40
+
41
+ const entityType = getFileTypeEntityFromExtension(extension);
42
+
43
+ if (!entityType) {
44
+ return {
45
+ success: false,
46
+ errors: [
47
+ createError(
48
+ `Invalid blob extension: ${extension} (must be js, figspec, or figstack)`,
49
+ ERROR_CODES.BLOB_INVALID_EXTENSION,
50
+ ),
51
+ ],
52
+ };
53
+ }
54
+
55
+ try {
56
+ const path = helpers.fs.resolvePath(settings.repoRoot, file.path);
57
+ const content = helpers.fs.readFileAsUtf8(path);
58
+ const actualHash = helpers.crypto.createSha256HexHash(content);
59
+
60
+ if (actualHash !== expectedHash) {
61
+ return {
62
+ success: false,
63
+ errors: [
64
+ createError(
65
+ `Hash mismatch: filename hash ${expectedHash} does not match content hash ${actualHash}`,
66
+ ERROR_CODES.BLOB_HASH_MISMATCH,
67
+ ),
68
+ ],
69
+ };
70
+ }
71
+ } catch (error) {
72
+ return {
73
+ success: false,
74
+ errors: [
75
+ createError(
76
+ `Failed to verify blob: ${error instanceof Error ? error.message : String(error)}`,
77
+ ERROR_CODES.BLOB_VERIFICATION_FAILED,
78
+ ),
79
+ ],
80
+ };
81
+ }
82
+
83
+ return success();
84
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ ChangedFile,
3
+ FileTypeEntity,
4
+ getSchemaFromFileTypeEntity,
5
+ } from "../../changed-file.js";
6
+ import { getNamespaceEditorEntry } from "../namespace.js";
7
+ import { parseSchema } from "../parse.js";
8
+ import { checkPushLimit } from "../check-push-limit.js";
9
+ import { getNamespaceMetadataFromHead } from "../git-head.js";
10
+ import { validateBlobReferences } from "../validate-blob-references.js";
11
+ import {
12
+ ValidationResult,
13
+ ERROR_CODES,
14
+ createError,
15
+ success,
16
+ } from "../../validation-result.js";
17
+
18
+ export function validateEntity(
19
+ file: ChangedFile,
20
+ type: FileTypeEntity,
21
+ ): ValidationResult {
22
+ const { registry, prInfo } = file.pr;
23
+ const { settings, helpers } = registry;
24
+ const { fs } = helpers;
25
+
26
+ const namespace = file.getNamespace(type);
27
+
28
+ const isAllowedToChangeEntityFile = () => {
29
+ if (registry.isNamespaceRestricted(namespace)) {
30
+ if (!registry.isMaintainer(prInfo.author)) {
31
+ return {
32
+ success: false,
33
+ errors: [
34
+ createError(
35
+ `The ${namespace}/ namespace is reserved. Changes require maintainer approval.`,
36
+ ERROR_CODES.NAMESPACE_RESERVED,
37
+ ),
38
+ ],
39
+ };
40
+ }
41
+ } else {
42
+ const namespaceMetadata = getNamespaceMetadataFromHead(
43
+ namespace,
44
+ registry.helpers,
45
+ );
46
+ if (namespaceMetadata === null) {
47
+ return {
48
+ success: false,
49
+ errors: [
50
+ createError(
51
+ `Namespace "${namespace}" does not exist. Run \`figulus registry claim ${namespace}\` to claim it before publishing.`,
52
+ ERROR_CODES.NAMESPACE_NOT_FOUND,
53
+ ),
54
+ ],
55
+ };
56
+ }
57
+
58
+ if (!getNamespaceEditorEntry(prInfo.author, namespaceMetadata)) {
59
+ return {
60
+ success: false,
61
+ errors: [
62
+ createError(
63
+ `PR author "${prInfo.author}" is not listed as an editor for namespace "${namespace}"`,
64
+ ERROR_CODES.NAMESPACE_NOT_EDITOR,
65
+ ),
66
+ ],
67
+ };
68
+ }
69
+
70
+ const pushLimitError = checkPushLimit(
71
+ prInfo.author,
72
+ namespace,
73
+ helpers,
74
+ settings,
75
+ );
76
+ if (pushLimitError) {
77
+ return {
78
+ success: false,
79
+ errors: [pushLimitError],
80
+ };
81
+ }
82
+ }
83
+
84
+ return success();
85
+ };
86
+
87
+ const permissionResult = isAllowedToChangeEntityFile();
88
+ if (!permissionResult.success) return permissionResult;
89
+
90
+ try {
91
+ const data = file.parseJson();
92
+
93
+ const schemaResult = parseSchema(data, getSchemaFromFileTypeEntity(type));
94
+ if (!schemaResult.success) return schemaResult;
95
+
96
+ const blobResult = validateBlobReferences(data, namespace, type, registry);
97
+ if (!blobResult.success) return blobResult;
98
+
99
+ return success();
100
+ } catch (error) {
101
+ return {
102
+ success: false,
103
+ errors: [
104
+ createError(
105
+ `Failed to parse ${type}: ${error instanceof Error ? error.message : String(error)}`,
106
+ ERROR_CODES.ENTITY_PARSE_ERROR,
107
+ ),
108
+ ],
109
+ };
110
+ }
111
+ }
@@ -0,0 +1,179 @@
1
+ import { ChangedFile } from "../../changed-file.js";
2
+ import { namespaceMetadataSchema } from "@figulus/schema";
3
+ import { parseJSON, parseSchema } from "../parse.js";
4
+ import { validateOwnershipTransfer } from "../validate-ownership-transfer.js";
5
+ import {
6
+ ValidationResult,
7
+ ValidationError,
8
+ ERROR_CODES,
9
+ createError,
10
+ success,
11
+ } from "../../validation-result.js";
12
+ import {
13
+ validatePushLimitConstraints,
14
+ getPushLimitConstraintsForEditor,
15
+ } from "../validate-push-limit-constraints.js";
16
+
17
+ export function validateNamespaceMetadata(file: ChangedFile): ValidationResult {
18
+ const { registry, prInfo } = file.pr;
19
+ const { git } = registry.helpers;
20
+
21
+ const namespace = file.getNamespace("namespace");
22
+
23
+ if (registry.isNamespaceRestricted(namespace)) {
24
+ if (!registry.isMaintainer(prInfo.author))
25
+ return {
26
+ success: false,
27
+ errors: [
28
+ createError(
29
+ `Cannot create namespace "${namespace}": this name is reserved. You must be a registry maintainer to claim this namespace.`,
30
+ ERROR_CODES.NAMESPACE_RESERVED,
31
+ ),
32
+ ],
33
+ };
34
+ }
35
+
36
+ const prAuthor = file.pr.prInfo.author;
37
+
38
+ const validatePushLimitsInMetadata = (data: any): ValidationError[] => {
39
+ const errors: ValidationError[] = [];
40
+ const editors = data.editors || [];
41
+
42
+ for (const editor of editors) {
43
+ if (editor.pushLimit) {
44
+ const constraints = getPushLimitConstraintsForEditor(
45
+ registry.settings,
46
+ editor.pushLimit.unit,
47
+ );
48
+ const error = validatePushLimitConstraints(
49
+ editor.pushLimit,
50
+ constraints,
51
+ `Editor ${editor.username}`,
52
+ );
53
+ if (error) errors.push(error);
54
+ }
55
+ }
56
+
57
+ return errors;
58
+ };
59
+
60
+ if (namespace) {
61
+ try {
62
+ try {
63
+ const headContent = git.showHead(file.path);
64
+ const headData = parseJSON(headContent);
65
+
66
+ const headEditors = headData.editors || [];
67
+ const isEditorInHead = headEditors.some(
68
+ (e: any) => e.username === prAuthor,
69
+ );
70
+ if (!isEditorInHead)
71
+ return {
72
+ success: false,
73
+ errors: [
74
+ createError(
75
+ `PR author "${prAuthor}" is not listed as an editor in the existing namespace metadata`,
76
+ ERROR_CODES.NAMESPACE_NOT_EDITOR,
77
+ ),
78
+ ],
79
+ };
80
+
81
+ try {
82
+ const submittedData = file.parseJson();
83
+ const headOwner = headData.owner?.username;
84
+ const submittedOwner = submittedData.owner?.username;
85
+
86
+ const ownershipResult = validateOwnershipTransfer(
87
+ headOwner,
88
+ submittedOwner,
89
+ prAuthor,
90
+ );
91
+ if (!ownershipResult.success) {
92
+ return ownershipResult;
93
+ }
94
+ } catch {
95
+ // Continue - this error will be caught again during full validation
96
+ }
97
+
98
+ // Namespace exists in HEAD and author is a valid editor with no ownership violations
99
+ // Validate the submitted file against the schema
100
+ try {
101
+ const data = file.parseJson();
102
+ const schemaResult = parseSchema(data, namespaceMetadataSchema);
103
+ if (!schemaResult.success) return schemaResult;
104
+
105
+ const pushLimitErrors = validatePushLimitsInMetadata(data);
106
+ if (pushLimitErrors.length > 0) {
107
+ return { success: false, errors: pushLimitErrors };
108
+ }
109
+
110
+ return success();
111
+ } catch (error) {
112
+ return {
113
+ success: false,
114
+ errors: [
115
+ createError(
116
+ `Failed to parse namespace metadata: ${error instanceof Error ? error.message : String(error)}`,
117
+ ERROR_CODES.NAMESPACE_PARSE_ERROR,
118
+ ),
119
+ ],
120
+ };
121
+ }
122
+ } catch (parseError) {
123
+ return {
124
+ success: false,
125
+ errors: [
126
+ createError(
127
+ `Failed to parse HEAD version of namespace metadata: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
128
+ ERROR_CODES.NAMESPACE_PARSE_ERROR,
129
+ ),
130
+ ],
131
+ };
132
+ }
133
+ } catch {
134
+ // File doesn't exist in HEAD - this is a new namespace claim
135
+ // Fall through to validate the new submission
136
+ }
137
+ }
138
+
139
+ // If we reach here, the namespace doesn't exist in HEAD (new namespace claim)
140
+ try {
141
+ const data = file.parseJson();
142
+ const schemaResult = parseSchema(data, namespaceMetadataSchema);
143
+
144
+ if (!schemaResult.success) return schemaResult;
145
+
146
+ // Check push limit constraints
147
+ const pushLimitErrors = validatePushLimitsInMetadata(data);
148
+ if (pushLimitErrors.length > 0) {
149
+ return { success: false, errors: pushLimitErrors };
150
+ }
151
+
152
+ // For new namespaces, check that PR author is in editors
153
+ const editors = data.editors || [];
154
+ const isEditor = editors.some((e: any) => e.username === prAuthor);
155
+ if (!isEditor) {
156
+ return {
157
+ success: false,
158
+ errors: [
159
+ createError(
160
+ `PR author "${prAuthor}" is not listed as an editor in the namespace`,
161
+ ERROR_CODES.NAMESPACE_NOT_EDITOR,
162
+ ),
163
+ ],
164
+ };
165
+ }
166
+
167
+ return success();
168
+ } catch (error) {
169
+ return {
170
+ success: false,
171
+ errors: [
172
+ createError(
173
+ `Failed to parse namespace metadata: ${error instanceof Error ? error.message : String(error)}`,
174
+ ERROR_CODES.NAMESPACE_PARSE_ERROR,
175
+ ),
176
+ ],
177
+ };
178
+ }
179
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ ChangedFile,
3
+ FileTypeNamespaceOverrides,
4
+ getSchemaFromFileTypeNamespaceOverrides,
5
+ } from "../../changed-file.js";
6
+ import { parseSchema } from "../parse.js";
7
+ import {
8
+ ValidationResult,
9
+ ERROR_CODES,
10
+ createError,
11
+ } from "../../validation-result.js";
12
+ import {
13
+ validatePushLimitConstraints,
14
+ getPushLimitConstraintsForMaintainer,
15
+ } from "../validate-push-limit-constraints.js";
16
+
17
+ export function validateNamespaceOverrides(
18
+ file: ChangedFile,
19
+ fileType: FileTypeNamespaceOverrides,
20
+ ): ValidationResult {
21
+ const { registry, prInfo } = file.pr;
22
+ if (!registry.isMaintainer(prInfo.author)) {
23
+ return {
24
+ success: false,
25
+ errors: [
26
+ createError(
27
+ `Only registry maintainers can modify ${file.path}`,
28
+ ERROR_CODES.PERMISSION_DENIED_MAINTAINER_ONLY,
29
+ ),
30
+ ],
31
+ };
32
+ }
33
+
34
+ try {
35
+ const data = file.parseJson();
36
+ const schemaResult = parseSchema(
37
+ data,
38
+ getSchemaFromFileTypeNamespaceOverrides(fileType),
39
+ );
40
+
41
+ if (!schemaResult.success) return schemaResult;
42
+
43
+ // For limits overrides, validate push limit constraints
44
+ if (fileType === "limits") {
45
+ const errors = [];
46
+ const overrides = data || [];
47
+
48
+ for (const override of overrides) {
49
+ if (override.pushLimit) {
50
+ const constraints = getPushLimitConstraintsForMaintainer(
51
+ registry.settings,
52
+ override.pushLimit.unit,
53
+ );
54
+ const error = validatePushLimitConstraints(
55
+ override.pushLimit,
56
+ constraints,
57
+ `Namespace ${override.namespace}`,
58
+ );
59
+ if (error) errors.push(error);
60
+ }
61
+ }
62
+
63
+ if (errors.length > 0) {
64
+ return { success: false, errors };
65
+ }
66
+ }
67
+
68
+ return schemaResult;
69
+ } catch (error) {
70
+ return {
71
+ success: false,
72
+ errors: [
73
+ createError(
74
+ `Failed to parse ${file.path}: ${error instanceof Error ? error.message : String(error)}`,
75
+ ERROR_CODES.NAMESPACE_PARSE_ERROR,
76
+ ),
77
+ ],
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ ValidationResult,
3
+ ERROR_CODES,
4
+ createError,
5
+ success,
6
+ } from "../validation-result.js";
7
+
8
+ export function validateOwnershipTransfer(
9
+ headOwner: string | undefined,
10
+ submittedOwner: string | undefined,
11
+ prAuthor: string,
12
+ ): ValidationResult {
13
+ if (submittedOwner !== headOwner && prAuthor !== headOwner) {
14
+ return {
15
+ success: false,
16
+ errors: [
17
+ createError(
18
+ `Only the namespace owner ("${headOwner}") can transfer ownership`,
19
+ ERROR_CODES.NAMESPACE_OWNERSHIP_TRANSFER_DENIED,
20
+ ),
21
+ ],
22
+ };
23
+ }
24
+
25
+ return success();
26
+ }
@@ -0,0 +1,39 @@
1
+ import { Settings } from "../settings.js";
2
+ import {
3
+ ValidationError,
4
+ ERROR_CODES,
5
+ createError,
6
+ } from "../validation-result.js";
7
+
8
+ export interface PushLimitValue {
9
+ unit: "daily" | "weekly";
10
+ value: number;
11
+ }
12
+
13
+ export function validatePushLimitConstraints(
14
+ pushLimit: PushLimitValue,
15
+ constraints: { min: number; max: number },
16
+ context: string,
17
+ ): ValidationError | null {
18
+ if (pushLimit.value < constraints.min || pushLimit.value > constraints.max) {
19
+ return createError(
20
+ `${context} push limit ${pushLimit.value}/${pushLimit.unit} is outside allowed range: ${constraints.min}-${constraints.max}`,
21
+ ERROR_CODES.PUSH_LIMIT_EXCEEDED,
22
+ );
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function getPushLimitConstraintsForEditor(
28
+ settings: Settings,
29
+ unit: "daily" | "weekly",
30
+ ): { min: number; max: number } {
31
+ return settings.pushLimits.overridesSetBy.namespaceOwners[unit];
32
+ }
33
+
34
+ export function getPushLimitConstraintsForMaintainer(
35
+ settings: Settings,
36
+ unit: "daily" | "weekly",
37
+ ): { min: number; max: number } {
38
+ return settings.pushLimits.overridesSetBy.registryMaintainers[unit];
39
+ }
package/src/pr.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { RegistryValidator, Helpers } from "./registry-validator";
2
+ import {
3
+ PullRequestInfo,
4
+ ValidationSummary,
5
+ FileValidationResult,
6
+ } from "./types.js";
7
+ import { ChangedFile } from "./changed-file.js";
8
+ import { Settings } from "./settings.js";
9
+
10
+ export class PR {
11
+ constructor(
12
+ public prInfo: PullRequestInfo,
13
+ public registry: RegistryValidator,
14
+ ) {}
15
+
16
+ public getAuthor(): string {
17
+ return this.prInfo.author;
18
+ }
19
+
20
+ public getSettings(): Settings {
21
+ return this.registry.settings;
22
+ }
23
+
24
+ public getHelpers(): Helpers {
25
+ return this.registry.helpers;
26
+ }
27
+
28
+ public validate(): ValidationSummary {
29
+ const { prInfo, registry } = this;
30
+ const { helpers } = registry;
31
+
32
+ const results: FileValidationResult[] = [];
33
+ let filesWithErrors = 0;
34
+ let filesWithWarnings = 0;
35
+
36
+ if (prInfo.changedFiles.length === 0) {
37
+ helpers.console.log("No changed files to validate");
38
+ return {
39
+ success: true,
40
+ totalFiles: 0,
41
+ filesWithErrors: 0,
42
+ filesWithWarnings: 0,
43
+ results: [],
44
+ };
45
+ }
46
+
47
+ for (const file of prInfo.changedFiles) {
48
+ const result = new ChangedFile(file, this).validate();
49
+ results.push(result);
50
+
51
+ if (!result.result.success) {
52
+ filesWithErrors++;
53
+ } else if (result.result.warnings?.length) {
54
+ filesWithWarnings++;
55
+ }
56
+ }
57
+
58
+ // Output results
59
+ const hasErrors = filesWithErrors > 0;
60
+ if (hasErrors) {
61
+ helpers.console.error("\n❌ Validation failed:\n");
62
+ for (const result of results) {
63
+ if (!result.result.success) {
64
+ helpers.console.error(`📄 ${result.file}:`);
65
+ result.result.errors.forEach((err) => {
66
+ helpers.console.error(` • [${err.code}] ${err.message}`);
67
+ });
68
+ }
69
+ }
70
+ } else {
71
+ helpers.console.log("✅ All validations passed!");
72
+ }
73
+
74
+ if (filesWithWarnings > 0) {
75
+ helpers.console.log(
76
+ `\n⚠️ ${filesWithWarnings} file(s) with warnings:\n`,
77
+ );
78
+ for (const result of results) {
79
+ if (result.result.success && result.result.warnings?.length) {
80
+ helpers.console.log(`📄 ${result.file}:`);
81
+ result.result.warnings.forEach((warn) => {
82
+ helpers.console.log(` • [${warn.code}] ${warn.message}`);
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ return {
89
+ success: !hasErrors,
90
+ totalFiles: prInfo.changedFiles.length,
91
+ filesWithErrors,
92
+ filesWithWarnings,
93
+ results,
94
+ };
95
+ }
96
+ }
@@ -0,0 +1,49 @@
1
+ import { PR } from "./pr.js";
2
+ import { Settings } from "./settings.js";
3
+ import { PullRequestInfo } from "./types.js";
4
+
5
+ export interface Helpers {
6
+ console: {
7
+ log: (...data: any[]) => void;
8
+ error: (...data: any[]) => void;
9
+ };
10
+ crypto: {
11
+ createSha256HexHash: (data: string) => string;
12
+ };
13
+ fs: {
14
+ resolvePath: (...paths: string[]) => string;
15
+ splitPath: (path: string) => string[];
16
+ fileOrDirExists: (path: string) => boolean;
17
+ readFileAsUtf8: (path: string) => string;
18
+ };
19
+ git: {
20
+ showHead: (filePath: string) => string;
21
+ getAllPRs: () => {
22
+ url: string;
23
+ createdAt: string;
24
+ user: { id: string };
25
+ }[];
26
+ getPRFiles: (url: string) => {
27
+ filename: string;
28
+ }[];
29
+ };
30
+ }
31
+
32
+ export class RegistryValidator {
33
+ constructor(
34
+ public helpers: Helpers,
35
+ public settings: Settings,
36
+ ) {}
37
+
38
+ public validatePr(prInfo: PullRequestInfo) {
39
+ return new PR(prInfo, this);
40
+ }
41
+
42
+ public isMaintainer(user: string): boolean {
43
+ return this.settings.registryMaintainers.includes(user);
44
+ }
45
+
46
+ public isNamespaceRestricted(namespace: string): boolean {
47
+ return this.settings.restrictedNamespaces.includes(namespace);
48
+ }
49
+ }
@@ -0,0 +1,59 @@
1
+ import z from "zod";
2
+
3
+ const pushLimitOverridesSetBySchema = z.object({
4
+ daily: z.object({
5
+ max: z.int(),
6
+ min: z.int(),
7
+ }),
8
+ weekly: z.object({
9
+ max: z.int(),
10
+ min: z.int(),
11
+ }),
12
+ });
13
+
14
+ export const settingsSchema = z.object({
15
+ repoRoot: z.string(),
16
+ registryMaintainers: z
17
+ .string()
18
+ .array()
19
+ .optional()
20
+ .default(["figulusproject"]),
21
+ restrictedNamespaces: z
22
+ .string()
23
+ .array()
24
+ .optional()
25
+ .default([
26
+ "examples",
27
+ "figulus",
28
+ "official",
29
+ "push-limit-overrides",
30
+ "verified",
31
+ ]),
32
+ pushLimits: z
33
+ .object({
34
+ default: z.object({
35
+ unit: z.enum(["daily", "weekly"]),
36
+ pushes: z.int(),
37
+ }),
38
+ overridesSetBy: z.object({
39
+ namespaceOwners: pushLimitOverridesSetBySchema,
40
+ registryMaintainers: pushLimitOverridesSetBySchema,
41
+ }),
42
+ })
43
+ .optional()
44
+ .default({
45
+ default: { unit: "daily", pushes: 10 },
46
+ overridesSetBy: {
47
+ namespaceOwners: {
48
+ daily: { min: 1, max: 10 },
49
+ weekly: { min: 1, max: 10 * 7 },
50
+ },
51
+ registryMaintainers: {
52
+ daily: { min: 0, max: 30 },
53
+ weekly: { min: 0, max: 30 * 7 },
54
+ },
55
+ },
56
+ }),
57
+ });
58
+
59
+ export type Settings = z.infer<typeof settingsSchema>;
package/src/types.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { ZodArray, ZodObject } from "zod";
2
+ import { ValidationResult } from "./validation-result.js";
3
+
4
+ export type SchemaObject = ZodObject | ZodArray;
5
+
6
+ export interface PullRequestInfo {
7
+ changedFiles: string[];
8
+ author: string;
9
+ }
10
+
11
+ export interface FileValidationResult {
12
+ file: string;
13
+ type: string;
14
+ result: ValidationResult;
15
+ }
16
+
17
+ export interface ValidationSummary {
18
+ success: boolean;
19
+ totalFiles: number;
20
+ filesWithErrors: number;
21
+ filesWithWarnings: number;
22
+ results: FileValidationResult[];
23
+ }
@@ -0,0 +1,78 @@
1
+ export const ERROR_CODES = {
2
+ // File type errors
3
+ FILE_TYPE_UNKNOWN: "FILE_TYPE_UNKNOWN",
4
+
5
+ // Blob errors
6
+ BLOB_INVALID_FILENAME: "BLOB_INVALID_FILENAME",
7
+ BLOB_MISSING_EXTENSION: "BLOB_MISSING_EXTENSION",
8
+ BLOB_INVALID_EXTENSION: "BLOB_INVALID_EXTENSION",
9
+ BLOB_HASH_MISMATCH: "BLOB_HASH_MISMATCH",
10
+ BLOB_VERIFICATION_FAILED: "BLOB_VERIFICATION_FAILED",
11
+ BLOB_REFERENCE_NOT_FOUND: "BLOB_REFERENCE_NOT_FOUND",
12
+
13
+ // Entity errors
14
+ ENTITY_PARSE_ERROR: "ENTITY_PARSE_ERROR",
15
+ ENTITY_SCHEMA_INVALID: "ENTITY_SCHEMA_INVALID",
16
+
17
+ // Namespace errors
18
+ NAMESPACE_RESERVED: "NAMESPACE_RESERVED",
19
+ NAMESPACE_NOT_FOUND: "NAMESPACE_NOT_FOUND",
20
+ NAMESPACE_NOT_EDITOR: "NAMESPACE_NOT_EDITOR",
21
+ NAMESPACE_PARSE_ERROR: "NAMESPACE_PARSE_ERROR",
22
+ NAMESPACE_SCHEMA_INVALID: "NAMESPACE_SCHEMA_INVALID",
23
+ NAMESPACE_OWNERSHIP_TRANSFER_DENIED: "NAMESPACE_OWNERSHIP_TRANSFER_DENIED",
24
+
25
+ // Permissions
26
+ PERMISSION_DENIED_MAINTAINER_ONLY: "PERMISSION_DENIED_MAINTAINER_ONLY",
27
+
28
+ // Push limit
29
+ PUSH_LIMIT_EXCEEDED: "PUSH_LIMIT_EXCEEDED",
30
+ } as const;
31
+
32
+ export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
33
+
34
+ export interface ValidationError {
35
+ message: string;
36
+ code: ErrorCode;
37
+ }
38
+
39
+ export interface SuccessResult {
40
+ success: true;
41
+ warnings?: ValidationError[];
42
+ }
43
+
44
+ export interface FailureResult {
45
+ success: false;
46
+ errors: ValidationError[];
47
+ }
48
+
49
+ export type ValidationResult = SuccessResult | FailureResult;
50
+
51
+ export function createError(message: string, code: ErrorCode): ValidationError {
52
+ return { message, code };
53
+ }
54
+
55
+ export function createWarning(
56
+ message: string,
57
+ code: ErrorCode,
58
+ ): ValidationError {
59
+ return { message, code };
60
+ }
61
+
62
+ export function success(warnings: ValidationError[] = []): SuccessResult {
63
+ return warnings.length > 0 ? { success: true, warnings } : { success: true };
64
+ }
65
+
66
+ export function failure(errors: ValidationError[]): FailureResult {
67
+ return { success: false, errors };
68
+ }
69
+
70
+ export function failureFromStrings(
71
+ messages: string[],
72
+ code: ErrorCode,
73
+ ): FailureResult {
74
+ return {
75
+ success: false,
76
+ errors: messages.map((msg) => createError(msg, code)),
77
+ };
78
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true
10
+ },
11
+ "include": ["src/**/*.ts"],
12
+ "exclude": ["node_modules"]
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["src/**/*.ts"]
12
+ }