@loomstack/verifier 0.0.1

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.
@@ -0,0 +1,9 @@
1
+ import { VerifyResult } from '@loomstack/core';
2
+
3
+ interface VerifyOptions {
4
+ feature?: string;
5
+ generated?: boolean;
6
+ }
7
+ declare function verifyProject(root: string, options?: VerifyOptions): VerifyResult;
8
+
9
+ export { type VerifyOptions, verifyProject };
package/dist/index.js ADDED
@@ -0,0 +1,155 @@
1
+ // src/index.ts
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { join, relative } from "path";
4
+ import { frameworkError, scanProject, toProjectPath } from "@loomstack/core";
5
+ import { GENERATED_SOURCE_MARKER, generatedManifest, readGeneratedManifest, renderGeneratedFiles, sha256 } from "@loomstack/generator";
6
+ function filesRecursively(directory) {
7
+ if (!existsSync(directory)) return [];
8
+ return readdirSync(directory).sort().flatMap((name) => {
9
+ const path = join(directory, name);
10
+ return statSync(path).isDirectory() ? filesRecursively(path) : [path];
11
+ });
12
+ }
13
+ function belongsToScope(error, feature) {
14
+ if (!feature || !error.file) return true;
15
+ return error.file.startsWith(`features/${feature}/`) || error.relatedFiles?.some((file) => file.startsWith(`features/${feature}/`)) === true;
16
+ }
17
+ function contractErrors(feature) {
18
+ const errors = [];
19
+ const actions = new Set(feature.actions.map((item) => item.name));
20
+ const declaredActions = new Set(feature.manifest.actions);
21
+ for (const name of [...declaredActions].sort()) {
22
+ if (!actions.has(name)) errors.push(frameworkError("loomstack1003", { message: `Manifest action "${name}" is not exported.`, file: feature.manifestPath }));
23
+ }
24
+ for (const action of feature.actions) {
25
+ if (!declaredActions.has(action.name)) errors.push(frameworkError("loomstack1004", { message: `Exported action "${action.name}" is missing from feature.yaml.`, file: action.file, relatedFiles: [feature.manifestPath] }));
26
+ }
27
+ const queries = new Set(feature.queries.map((item) => item.name));
28
+ const declaredQueries = new Set(feature.manifest.queries);
29
+ for (const name of [...declaredQueries].sort()) {
30
+ if (!queries.has(name)) errors.push(frameworkError("loomstack1007", { message: `Manifest query "${name}" is not exported.`, file: feature.manifestPath }));
31
+ }
32
+ for (const query of feature.queries) {
33
+ if (!declaredQueries.has(query.name)) errors.push(frameworkError("loomstack1008", { message: `Exported query "${query.name}" is missing from feature.yaml.`, file: query.file, relatedFiles: [feature.manifestPath] }));
34
+ }
35
+ const views = new Set(feature.views.map((item) => item.name));
36
+ for (const route of feature.manifest.routes) {
37
+ if (!views.has(route.view)) errors.push(frameworkError("loomstack1009", { message: `Route "${route.id}" references missing view "${route.view}".`, file: feature.manifestPath }));
38
+ }
39
+ const schemas = new Set(feature.schemas.map((item) => item.name));
40
+ for (const entity of feature.manifest.entities) {
41
+ if (!schemas.has(entity)) errors.push(frameworkError("loomstack1010", { message: `Manifest entity "${entity}" is not exported by model.schema.ts.`, file: feature.manifestPath }));
42
+ }
43
+ return errors;
44
+ }
45
+ function duplicateOperationErrors(project) {
46
+ const errors = [];
47
+ for (const key of ["actions", "queries"]) {
48
+ const owners = /* @__PURE__ */ new Map();
49
+ for (const feature of project.features) {
50
+ for (const item of feature[key]) {
51
+ const owner = owners.get(item.name);
52
+ if (owner) {
53
+ errors.push(frameworkError(key === "actions" ? "loomstack1004" : "loomstack1008", {
54
+ message: `Duplicate ${key.slice(0, -1)} name "${item.name}" is exported by ${owner} and ${feature.id}.`,
55
+ file: item.file
56
+ }));
57
+ } else owners.set(item.name, feature.id);
58
+ }
59
+ }
60
+ }
61
+ return errors;
62
+ }
63
+ function boundaryErrors(project, scope) {
64
+ const errors = [];
65
+ const features = scope ? project.features.filter((feature) => feature.id === scope) : project.features;
66
+ for (const feature of features) {
67
+ const files = filesRecursively(join(project.root, feature.directory)).filter((path) => /\.(ts|tsx)$/.test(path));
68
+ for (const absolute of files) {
69
+ const file = toProjectPath(relative(project.root, absolute));
70
+ const source = readFileSync(absolute, "utf8");
71
+ const imports = [...source.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)].map((match) => match[1] ?? "");
72
+ const isUi = file.includes("/ui/");
73
+ if (isUi && imports.some((specifier) => /(^|[\/@.-])(db|database|postgres)([\/@.-]|$)/i.test(specifier))) {
74
+ errors.push(frameworkError("loomstack2001", { file }));
75
+ }
76
+ if (isUi && /\bfetch\s*\(/.test(source)) errors.push(frameworkError("loomstack2002", { file }));
77
+ if ((file.includes("/actions/") || file.includes("/queries/") || isUi) && imports.some((specifier) => specifier === "koa" || specifier.startsWith("@koa/"))) {
78
+ errors.push(frameworkError("loomstack2003", { file }));
79
+ }
80
+ }
81
+ }
82
+ return errors;
83
+ }
84
+ function generatedErrors(project) {
85
+ const errors = [];
86
+ let expected;
87
+ try {
88
+ expected = renderGeneratedFiles(project);
89
+ } catch {
90
+ return errors;
91
+ }
92
+ const expectedManifest = generatedManifest(expected);
93
+ const recorded = readGeneratedManifest(project);
94
+ const manifestPath = `${project.config.generatedDir}/generated-files.json`;
95
+ if (!recorded || recorded.generatedBy !== "loomstack" || recorded.doNotEdit !== true || !Array.isArray(recorded.files)) {
96
+ return [frameworkError("loomstack4002", { message: `${manifestPath} is missing or invalid.`, file: manifestPath })];
97
+ }
98
+ const records = new Map(recorded.files.map((item) => [item.path, item.sha256]));
99
+ for (const [path, expectedContent] of Object.entries(expected)) {
100
+ const absolute = join(project.root, path);
101
+ if (!existsSync(absolute)) {
102
+ errors.push(frameworkError("loomstack4002", { message: `Generated file is missing: ${path}.`, file: path }));
103
+ continue;
104
+ }
105
+ const actualContent = readFileSync(absolute, "utf8");
106
+ const isSource = /\.(ts|tsx)$/.test(path);
107
+ let marked = false;
108
+ if (isSource) marked = actualContent.startsWith(GENERATED_SOURCE_MARKER);
109
+ else {
110
+ try {
111
+ const json = JSON.parse(actualContent);
112
+ marked = json.generatedBy === "loomstack" && json.doNotEdit === true;
113
+ } catch {
114
+ marked = false;
115
+ }
116
+ }
117
+ if (!marked) {
118
+ errors.push(frameworkError("loomstack4001", { message: `Generated marker is missing from ${path}.`, file: path }));
119
+ continue;
120
+ }
121
+ const actualHash = sha256(actualContent);
122
+ const recordedHash = records.get(path);
123
+ const expectedHash = sha256(expectedContent);
124
+ if (!recordedHash) {
125
+ errors.push(frameworkError("loomstack4002", { message: `${path} is not tracked in ${manifestPath}.`, file: path }));
126
+ } else if (actualHash !== recordedHash && actualHash !== expectedHash) {
127
+ errors.push(frameworkError("loomstack4001", { file: path }));
128
+ } else if (actualHash !== expectedHash || recordedHash !== expectedHash) {
129
+ errors.push(frameworkError("loomstack4002", { message: `${path} is stale.`, file: path }));
130
+ }
131
+ }
132
+ const expectedPaths = new Set(expectedManifest.files.map((item) => item.path));
133
+ for (const record of recorded.files) {
134
+ if (!expectedPaths.has(record.path)) errors.push(frameworkError("loomstack4002", { message: `Generated manifest tracks obsolete file ${record.path}.`, file: manifestPath, relatedFiles: [record.path] }));
135
+ }
136
+ return errors;
137
+ }
138
+ function verifyProject(root, options = {}) {
139
+ const scanned = scanProject(root);
140
+ const errors = scanned.errors.filter((error) => belongsToScope(error, options.feature));
141
+ if (!scanned.project) return { ok: false, errors, warnings: [] };
142
+ const selected = options.feature ? scanned.project.features.filter((feature) => feature.id === options.feature) : scanned.project.features;
143
+ if (options.feature && selected.length === 0) {
144
+ errors.push(frameworkError("loomstack1002", { message: `Unknown feature: ${options.feature}.`, file: `${scanned.project.config.featuresDir}/${options.feature}` }));
145
+ }
146
+ for (const feature of selected) errors.push(...contractErrors(feature));
147
+ errors.push(...duplicateOperationErrors(scanned.project).filter((error) => belongsToScope(error, options.feature)));
148
+ errors.push(...boundaryErrors(scanned.project, options.feature));
149
+ if (options.generated !== false) errors.push(...generatedErrors(scanned.project));
150
+ const unique = [...new Map(errors.map((error) => [`${error.code}:${error.file ?? ""}:${error.message}`, error])).values()].sort((a, b) => (a.file ?? "").localeCompare(b.file ?? "") || a.code.localeCompare(b.code));
151
+ return { ok: unique.length === 0, errors: unique, warnings: [] };
152
+ }
153
+ export {
154
+ verifyProject
155
+ };
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@loomstack/verifier",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@loomstack/core": "0.0.1",
17
+ "@loomstack/generator": "0.0.1"
18
+ },
19
+ "scripts": {
20
+ "build": "tsup src/index.ts --format esm --dts --clean",
21
+ "test": "vitest run"
22
+ }
23
+ }