@anhcodo13/lattice 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +1045 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +290 -0
- package/governance/policy.json +23 -0
- package/package.json +34 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
type ProfileName = "next" | "vite-react" | "react";
|
|
2
|
+
interface VersionPolicy {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
policyVersion: string;
|
|
5
|
+
profiles: Record<string, Record<string, string>>;
|
|
6
|
+
source: string;
|
|
7
|
+
sha256: string;
|
|
8
|
+
}
|
|
9
|
+
interface GovernanceFinding {
|
|
10
|
+
app: string;
|
|
11
|
+
packageName: string;
|
|
12
|
+
expected: string;
|
|
13
|
+
declared?: string;
|
|
14
|
+
locked?: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
interface GovernanceResult {
|
|
18
|
+
appsChecked: number;
|
|
19
|
+
findings: GovernanceFinding[];
|
|
20
|
+
policyVersion: string;
|
|
21
|
+
policySource: string;
|
|
22
|
+
policySha256: string;
|
|
23
|
+
}
|
|
24
|
+
interface CheckOptions {
|
|
25
|
+
scanRoot: string;
|
|
26
|
+
workspaceRoot?: string;
|
|
27
|
+
policy: VersionPolicy;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare function loadPolicy(source: string): Promise<VersionPolicy>;
|
|
31
|
+
|
|
32
|
+
declare function checkGovernance(options: CheckOptions): Promise<GovernanceResult>;
|
|
33
|
+
|
|
34
|
+
export { type CheckOptions, type GovernanceFinding, type GovernanceResult, type ProfileName, type VersionPolicy, checkGovernance, loadPolicy };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// src/governance/policy.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function parsePolicy(raw, source) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(`Policy at ${source} is not valid JSON`);
|
|
15
|
+
}
|
|
16
|
+
if (!record(parsed) || parsed.schemaVersion !== 1) {
|
|
17
|
+
throw new Error("Policy schemaVersion must be 1");
|
|
18
|
+
}
|
|
19
|
+
if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion)) {
|
|
20
|
+
throw new Error("Policy policyVersion must be an exact version");
|
|
21
|
+
}
|
|
22
|
+
if (!record(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
23
|
+
throw new Error("Policy profiles must be a nonempty object");
|
|
24
|
+
}
|
|
25
|
+
const profiles2 = {};
|
|
26
|
+
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
27
|
+
if (!record(rules) || Object.keys(rules).length === 0) {
|
|
28
|
+
throw new Error(`Policy profile ${name} must list packages`);
|
|
29
|
+
}
|
|
30
|
+
const versions = {};
|
|
31
|
+
for (const [packageName, version] of Object.entries(rules)) {
|
|
32
|
+
if (typeof version !== "string" || !exactVersion.test(version)) {
|
|
33
|
+
throw new Error(`Policy exact version required for ${packageName} in ${name}`);
|
|
34
|
+
}
|
|
35
|
+
versions[packageName] = version;
|
|
36
|
+
}
|
|
37
|
+
profiles2[name] = versions;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
policyVersion: parsed.policyVersion,
|
|
42
|
+
profiles: profiles2,
|
|
43
|
+
source,
|
|
44
|
+
sha256: createHash("sha256").update(raw).digest("hex")
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async function loadPolicy(source) {
|
|
48
|
+
if (/^https?:\/\//i.test(source)) {
|
|
49
|
+
if (!source.startsWith("https://")) {
|
|
50
|
+
throw new Error("Remote policy requires HTTPS");
|
|
51
|
+
}
|
|
52
|
+
const response = await fetch(source);
|
|
53
|
+
if (response.url && !response.url.startsWith("https://")) {
|
|
54
|
+
throw new Error("Remote policy redirects must remain on HTTPS");
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`Policy fetch failed with HTTP ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
return parsePolicy(await response.text(), source);
|
|
60
|
+
}
|
|
61
|
+
return parsePolicy(await readFile(resolve(source), "utf8"), source);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/governance/check.ts
|
|
65
|
+
import { resolve as resolve3, relative as relative3, sep as sep2 } from "path";
|
|
66
|
+
|
|
67
|
+
// src/governance/discover.ts
|
|
68
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
69
|
+
import { join, relative } from "path";
|
|
70
|
+
import fastGlob from "fast-glob";
|
|
71
|
+
var profiles = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
72
|
+
function isRecord(value) {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
function dependencyMap(manifest) {
|
|
76
|
+
const result = {};
|
|
77
|
+
for (const section2 of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
78
|
+
const entries = manifest[section2];
|
|
79
|
+
if (entries === void 0) continue;
|
|
80
|
+
if (!isRecord(entries)) throw new Error(`Invalid ${section2} in package.json`);
|
|
81
|
+
for (const [name, version] of Object.entries(entries)) {
|
|
82
|
+
if (typeof version !== "string") throw new Error(`Invalid version for ${name}`);
|
|
83
|
+
if (result[name] !== void 0 && result[name] !== version) {
|
|
84
|
+
throw new Error(`Conflicting declarations for ${name}`);
|
|
85
|
+
}
|
|
86
|
+
result[name] = version;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function profileFor(manifest, dependencies) {
|
|
92
|
+
const config = manifest.nklattice;
|
|
93
|
+
if (config !== void 0) {
|
|
94
|
+
if (!isRecord(config) || typeof config.profile !== "string" || !profiles.has(config.profile)) {
|
|
95
|
+
throw new Error("Invalid nklattice.profile in package.json");
|
|
96
|
+
}
|
|
97
|
+
return config.profile;
|
|
98
|
+
}
|
|
99
|
+
if (dependencies.next !== void 0) return "next";
|
|
100
|
+
if (dependencies.vite !== void 0 && dependencies.react !== void 0) return "vite-react";
|
|
101
|
+
if (dependencies.react !== void 0) return "react";
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
async function discoverApps(scanRoot, workspaceRoot) {
|
|
105
|
+
const manifests = await fastGlob(["package.json", "**/package.json"], {
|
|
106
|
+
cwd: scanRoot,
|
|
107
|
+
onlyFiles: true,
|
|
108
|
+
unique: true,
|
|
109
|
+
followSymbolicLinks: false,
|
|
110
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
|
|
111
|
+
});
|
|
112
|
+
const apps = [];
|
|
113
|
+
for (const path of manifests.sort()) {
|
|
114
|
+
const directory = join(scanRoot, path, "..");
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(await readFile2(join(scanRoot, path), "utf8"));
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error(`Invalid package.json: ${path}`);
|
|
120
|
+
}
|
|
121
|
+
if (!isRecord(parsed)) throw new Error(`Invalid package.json: ${path}`);
|
|
122
|
+
const dependencies = dependencyMap(parsed);
|
|
123
|
+
const profile = profileFor(parsed, dependencies);
|
|
124
|
+
if (!profile) continue;
|
|
125
|
+
apps.push({
|
|
126
|
+
directory,
|
|
127
|
+
relativePath: relative(workspaceRoot, directory).replaceAll("\\", "/") || ".",
|
|
128
|
+
profile,
|
|
129
|
+
dependencies
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return apps;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/governance/lockfiles.ts
|
|
136
|
+
import { access, readFile as readFile3 } from "fs/promises";
|
|
137
|
+
import { dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "path";
|
|
138
|
+
import YAML from "yaml";
|
|
139
|
+
function record2(value) {
|
|
140
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
141
|
+
}
|
|
142
|
+
async function exists(path) {
|
|
143
|
+
try {
|
|
144
|
+
await access(path);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function within(parent, child) {
|
|
151
|
+
const rel = relative2(parent, child);
|
|
152
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
153
|
+
}
|
|
154
|
+
async function findLockfile(appDirectory, workspaceRoot) {
|
|
155
|
+
let directory = resolve2(appDirectory);
|
|
156
|
+
const boundary = resolve2(workspaceRoot);
|
|
157
|
+
if (!within(boundary, directory)) throw new Error("Application is outside workspace root");
|
|
158
|
+
while (within(boundary, directory)) {
|
|
159
|
+
const npm = join2(directory, "package-lock.json");
|
|
160
|
+
const pnpm = join2(directory, "pnpm-lock.yaml");
|
|
161
|
+
const [hasNpm, hasPnpm] = await Promise.all([exists(npm), exists(pnpm)]);
|
|
162
|
+
if (hasNpm && hasPnpm) throw new Error(`Ambiguous lockfiles in ${directory}`);
|
|
163
|
+
if (hasNpm) return { path: npm, kind: "npm", root: directory };
|
|
164
|
+
if (hasPnpm) return { path: pnpm, kind: "pnpm", root: directory };
|
|
165
|
+
if (directory === boundary) break;
|
|
166
|
+
directory = dirname(directory);
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`No lockfile found for ${appDirectory}`);
|
|
169
|
+
}
|
|
170
|
+
function section(importer, name) {
|
|
171
|
+
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
172
|
+
const entries = importer[key];
|
|
173
|
+
if (record2(entries) && Object.hasOwn(entries, name)) return entries[name];
|
|
174
|
+
}
|
|
175
|
+
return void 0;
|
|
176
|
+
}
|
|
177
|
+
function readNpm(raw, root, app, name) {
|
|
178
|
+
let lock;
|
|
179
|
+
try {
|
|
180
|
+
lock = JSON.parse(raw);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error("Malformed npm lockfile JSON");
|
|
183
|
+
}
|
|
184
|
+
if (!record2(lock) || lock.lockfileVersion !== 3 || !record2(lock.packages)) {
|
|
185
|
+
throw new Error("Unsupported or malformed npm lockfile");
|
|
186
|
+
}
|
|
187
|
+
const importerKey = relative2(root, app.directory).replaceAll("\\", "/");
|
|
188
|
+
const importer = lock.packages[importerKey];
|
|
189
|
+
if (!record2(importer)) throw new Error(`Missing npm lockfile importer ${importerKey || "."}`);
|
|
190
|
+
const specifier = section(importer, name);
|
|
191
|
+
if (specifier !== void 0 && typeof specifier !== "string") {
|
|
192
|
+
throw new Error(`Malformed npm lockfile specifier for ${name}`);
|
|
193
|
+
}
|
|
194
|
+
let resolved;
|
|
195
|
+
const segments = importerKey ? importerKey.split("/") : [];
|
|
196
|
+
while (true) {
|
|
197
|
+
const prefix = segments.length ? `${segments.join("/")}/` : "";
|
|
198
|
+
resolved = lock.packages[`${prefix}node_modules/${name}`];
|
|
199
|
+
if (resolved !== void 0 || segments.length === 0) break;
|
|
200
|
+
segments.pop();
|
|
201
|
+
}
|
|
202
|
+
if (specifier !== void 0 && !record2(resolved)) {
|
|
203
|
+
throw new Error(`Missing npm lockfile package resolution for ${name}`);
|
|
204
|
+
}
|
|
205
|
+
if (record2(resolved) && typeof resolved.version !== "string") {
|
|
206
|
+
throw new Error(`Malformed npm lockfile package version for ${name}`);
|
|
207
|
+
}
|
|
208
|
+
return { specifier, version: record2(resolved) ? resolved.version : void 0 };
|
|
209
|
+
}
|
|
210
|
+
function readPnpm(raw, root, app, name) {
|
|
211
|
+
let lock;
|
|
212
|
+
try {
|
|
213
|
+
lock = YAML.parse(raw);
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Error("Malformed pnpm lockfile YAML");
|
|
216
|
+
}
|
|
217
|
+
if (!record2(lock) || String(lock.lockfileVersion) !== "9.0" || !record2(lock.importers) || !record2(lock.packages) || !record2(lock.snapshots)) {
|
|
218
|
+
throw new Error("Unsupported or malformed pnpm lockfile");
|
|
219
|
+
}
|
|
220
|
+
const importerKey = relative2(root, app.directory).replaceAll("\\", "/") || ".";
|
|
221
|
+
const importer = lock.importers[importerKey];
|
|
222
|
+
if (!record2(importer)) throw new Error(`Missing pnpm lockfile importer ${importerKey}`);
|
|
223
|
+
const entry = section(importer, name);
|
|
224
|
+
if (entry === void 0) return {};
|
|
225
|
+
if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
|
|
226
|
+
throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
|
|
227
|
+
}
|
|
228
|
+
const version = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
|
|
229
|
+
if (!version) throw new Error(`Unsupported pnpm resolution for ${name}`);
|
|
230
|
+
const packageKeys = [`${name}@${version}`, `/${name}@${version}`];
|
|
231
|
+
if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
|
|
232
|
+
throw new Error(`Missing pnpm package entry for ${name}@${version}`);
|
|
233
|
+
}
|
|
234
|
+
const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
|
|
235
|
+
if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
|
|
236
|
+
throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
|
|
237
|
+
}
|
|
238
|
+
return { specifier: entry.specifier, version };
|
|
239
|
+
}
|
|
240
|
+
async function readResolution(app, workspaceRoot, name) {
|
|
241
|
+
const lockfile = await findLockfile(app.directory, workspaceRoot);
|
|
242
|
+
const raw = await readFile3(lockfile.path, "utf8");
|
|
243
|
+
return lockfile.kind === "npm" ? readNpm(raw, lockfile.root, app, name) : readPnpm(raw, lockfile.root, app, name);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/governance/check.ts
|
|
247
|
+
async function checkGovernance(options) {
|
|
248
|
+
const scanRoot = resolve3(options.scanRoot);
|
|
249
|
+
const workspaceRoot = resolve3(options.workspaceRoot ?? scanRoot);
|
|
250
|
+
const path = relative3(workspaceRoot, scanRoot);
|
|
251
|
+
if (path === ".." || path.startsWith(`..${sep2}`)) {
|
|
252
|
+
throw new Error("Scan root must be inside workspace root");
|
|
253
|
+
}
|
|
254
|
+
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
255
|
+
if (apps.length === 0) throw new Error("No managed applications found");
|
|
256
|
+
const findings = [];
|
|
257
|
+
for (const app of apps) {
|
|
258
|
+
const requirements = options.policy.profiles[app.profile];
|
|
259
|
+
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
260
|
+
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
261
|
+
const declared = app.dependencies[packageName];
|
|
262
|
+
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
263
|
+
const reasons = [];
|
|
264
|
+
if (declared !== expected) reasons.push("manifest version differs");
|
|
265
|
+
if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
|
|
266
|
+
if (resolution.version !== expected) reasons.push("lockfile resolution differs");
|
|
267
|
+
if (reasons.length) {
|
|
268
|
+
findings.push({
|
|
269
|
+
app: app.relativePath,
|
|
270
|
+
packageName,
|
|
271
|
+
expected,
|
|
272
|
+
declared,
|
|
273
|
+
locked: resolution.version,
|
|
274
|
+
reason: reasons.join("; ")
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
appsChecked: apps.length,
|
|
281
|
+
findings,
|
|
282
|
+
policyVersion: options.policy.policyVersion,
|
|
283
|
+
policySource: options.policy.source,
|
|
284
|
+
policySha256: options.policy.sha256
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
export {
|
|
288
|
+
checkGovernance,
|
|
289
|
+
loadPolicy
|
|
290
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"policyVersion": "1.0.0",
|
|
4
|
+
"profiles": {
|
|
5
|
+
"next": {
|
|
6
|
+
"next": "15.5.6",
|
|
7
|
+
"react": "19.2.0",
|
|
8
|
+
"react-dom": "19.2.0",
|
|
9
|
+
"typescript": "5.9.3"
|
|
10
|
+
},
|
|
11
|
+
"vite-react": {
|
|
12
|
+
"vite": "6.4.3",
|
|
13
|
+
"react": "19.2.0",
|
|
14
|
+
"react-dom": "19.2.0",
|
|
15
|
+
"typescript": "5.9.3"
|
|
16
|
+
},
|
|
17
|
+
"react": {
|
|
18
|
+
"react": "19.2.0",
|
|
19
|
+
"react-dom": "19.2.0",
|
|
20
|
+
"typescript": "5.9.3"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anhcodo13/lattice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"packageManager": "pnpm@10.30.3",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"description": "Independent architecture analysis and dependency governance for React teams",
|
|
8
|
+
"author": "KhucHieuNghi",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/KhucHieuNghi/nklattice.git" },
|
|
11
|
+
"homepage": "https://khuchieunghi.github.io/nklattice/",
|
|
12
|
+
"bugs": { "url": "https://github.com/KhucHieuNghi/nklattice/issues" },
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
14
|
+
"bin": { "lattice": "dist/cli.js" },
|
|
15
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
16
|
+
"files": ["dist", "governance/policy.json", "LICENSE"],
|
|
17
|
+
"engines": { "node": ">=24" },
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"verify": "pnpm typecheck && pnpm test && pnpm build && node scripts/smoke-built-cli.mjs",
|
|
23
|
+
"verify:package": "node scripts/smoke-tarball.mjs"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"commander": "^14.0.0",
|
|
27
|
+
"fast-glob": "^3.3.3",
|
|
28
|
+
"typescript": "5.9.3",
|
|
29
|
+
"yaml": "^2.9.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^24.0.0",
|
|
33
|
+
"tsup": "^8.5.0",
|
|
34
|
+
"vitest": "^3.2.0"
|
|
35
|
+
}
|
|
36
|
+
}
|