@schneiderjoseph/devia 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-09
4
+
5
+ The standard is unchanged: `VERSION` stays at 0.1.0, no adopter needs `devia sync`.
6
+
7
+ ### A manifest is not always at the root
8
+
9
+ Running `devia init` on a real project for the first time — a Next.js app whose manifest lives
10
+ in `apps/web/` — exposed five gates reporting `SKIP no package.json` to a repository that has
11
+ one, with a lockfile, a lint script and thirteen dependencies. The letter of the rule was kept,
12
+ since nothing was rounded up to `PASS`; the reason given was false, which is worse. A reader
13
+ believes the tool looked.
14
+
15
+ - `check` reads every `package.json` in the repository, nearest the root first, and takes the
16
+ union of their dependencies: "does this project use X" is not a question about one directory
17
+ - A lockfile is looked for next to each manifest, not only at the root
18
+ - A migrations directory is found at any depth
19
+ - `SKIP` now says `no package.json anywhere in the repository`, and findings name the file they
20
+ came from — `no npm test script in apps/web/package.json`
21
+ - `init` detects the profile from the nearest manifest instead of falling back to a default, and
22
+ records the directories holding manifests in `code.paths`, which is what `doctor` watches for
23
+ staleness
24
+
25
+ On that project: six SKIPs became two, four gates turned into real findings, and the dependency
26
+ lockfile went from invisible to `PASS apps/web/package-lock.json`.
27
+
28
+ - G1 closed and reframed: the blind spot was never the ecosystem, it was the root assumption.
29
+ D9 records what is still root-only: `pyproject.toml`, `go.mod`, `Cargo.toml`
30
+ - G7 opened: what devia should do when a repository already carries an ad-hoc memory of its own
31
+
32
+ ### Fixed
33
+
34
+ - Nested directories were not excluded from the scan on Windows when git was unavailable: the
35
+ separator class only matched `/`, so `apps/web/node_modules` was walked. Both separators now.
36
+
3
37
  ## 0.3.0 — 2026-09-09
4
38
 
5
39
  The standard is unchanged: `VERSION` stays at 0.1.0, no adopter needs `devia sync`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schneiderjoseph/devia",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "One standard, one memory: engineering and design rules plus living project memory for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,7 +59,9 @@ const TEXT_EXT = new Set([
59
59
  * secret scan that silently scanned nothing is worse than one that over-reports.
60
60
  */
61
61
  function sourceFiles(root) {
62
- const skipped = (rel) => rel.split(/[\/]/).some((seg) => SKIP_DIRS.has(seg));
62
+ // Both separators: git reports "/", walk() reports path.sep, and a nested node_modules must be
63
+ // skipped in either shape.
64
+ const skipped = (rel) => rel.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg));
63
65
  const wanted = (rel) => TEXT_EXT.has(path.extname(rel).toLowerCase());
64
66
 
65
67
  const tracked = trackedFiles(root);
@@ -81,13 +83,31 @@ function anyExists(root, candidates) {
81
83
  }
82
84
 
83
85
  function makeChecks(root, ctx) {
84
- const pkg = readJSON(path.join(root, "package.json"));
85
- const scripts = pkg?.scripts || {};
86
- const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
87
86
  const config = readJSON(path.join(root, ".devia", "devia.json"));
88
87
  const files = sourceFiles(root);
89
88
  const rel = (p) => p.split(path.sep).join("/");
90
89
 
90
+ /**
91
+ * A manifest is not always at the repository root: `apps/web/package.json` is as real as
92
+ * `./package.json`. Reading only the root turned "I did not look there" into "you have no
93
+ * package.json", which is a wrong answer dressed as a SKIP — worse than no answer, because
94
+ * the reader believes the tool looked.
95
+ */
96
+ const manifests = files
97
+ .filter((f) => /(^|[\\/])package\.json$/.test(f))
98
+ .sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
99
+ const primary = manifests[0] || null;
100
+ const pkg = primary ? readJSON(path.join(root, primary)) : null;
101
+ const pkgWhere = primary ? rel(primary) : null;
102
+ const scripts = pkg?.scripts || {};
103
+ // Dependencies are asked as "does this project use X anywhere", so every manifest counts.
104
+ const deps = {};
105
+ for (const m of manifests) {
106
+ const json = readJSON(path.join(root, m)) || {};
107
+ Object.assign(deps, json.dependencies, json.devDependencies);
108
+ }
109
+ const noManifest = `no package.json anywhere in the repository`;
110
+
91
111
  const hasDep = (...names) => names.some((n) => n in deps);
92
112
  const grepFiles = (re, limit = 40) => {
93
113
  const hits = [];
@@ -305,12 +325,13 @@ function makeChecks(root, ctx) {
305
325
  rule: "TST-001",
306
326
  title: "A test command exists",
307
327
  run: () => {
308
- if (!pkg) return { kind: "SKIP", detail: "no package.json" };
328
+ if (!pkg) return { kind: "SKIP", detail: noManifest };
309
329
  const t = scripts.test;
310
- if (!t) return { kind: "WARN", detail: "no npm test script" };
330
+ const where = pkgWhere === "package.json" ? "" : ` in ${pkgWhere}`;
331
+ if (!t) return { kind: "WARN", detail: `no npm test script${where}` };
311
332
  return /no test specified/.test(t)
312
- ? { kind: "FAIL", detail: "test script is the npm placeholder" }
313
- : { kind: "PASS" };
333
+ ? { kind: "FAIL", detail: `test script is the npm placeholder${where}` }
334
+ : { kind: "PASS", detail: where.trim() };
314
335
  },
315
336
  },
316
337
  {
@@ -319,9 +340,7 @@ function makeChecks(root, ctx) {
319
340
  rule: "OPS-004",
320
341
  title: "Dependency lockfile committed",
321
342
  run: () => {
322
- const manifests = ["package.json", "pyproject.toml", "go.mod", "Cargo.toml", "Gemfile"];
323
- if (!anyExists(root, manifests)) return { kind: "SKIP", detail: "no manifest" };
324
- const lock = anyExists(root, [
343
+ const LOCKS = [
325
344
  "package-lock.json",
326
345
  "pnpm-lock.yaml",
327
346
  "yarn.lock",
@@ -331,10 +350,23 @@ function makeChecks(root, ctx) {
331
350
  "go.sum",
332
351
  "Cargo.lock",
333
352
  "Gemfile.lock",
353
+ ];
354
+ // A lockfile sits next to the manifest it locks, which is not always the root.
355
+ const dirs = new Set(["."]);
356
+ for (const m of manifests) dirs.add(path.dirname(m));
357
+ const rootManifest = anyExists(root, [
358
+ "package.json",
359
+ "pyproject.toml",
360
+ "go.mod",
361
+ "Cargo.toml",
362
+ "Gemfile",
334
363
  ]);
335
- return lock
336
- ? { kind: "PASS", detail: lock }
337
- : { kind: "FAIL", detail: "no lockfile" };
364
+ if (!rootManifest && !manifests.length) return { kind: "SKIP", detail: "no manifest" };
365
+ for (const dir of dirs) {
366
+ const found = LOCKS.find((l) => exists(path.join(root, dir, l)));
367
+ if (found) return { kind: "PASS", detail: rel(path.join(dir, found)) };
368
+ }
369
+ return { kind: "FAIL", detail: "no lockfile" };
338
370
  },
339
371
  },
340
372
  {
@@ -355,8 +387,14 @@ function makeChecks(root, ctx) {
355
387
  "alembic",
356
388
  path.join("src", "migrations"),
357
389
  ]);
358
- return dir
359
- ? { kind: "PASS", detail: dir }
390
+ if (dir) return { kind: "PASS", detail: dir };
391
+ // Not only at the root: a migrations directory can live under any package.
392
+ const SEGMENTS = new Set(["migrations", "migrate", "alembic"]);
393
+ const nested = files
394
+ .map(rel)
395
+ .find((f) => f.split("/").slice(0, -1).some((seg) => SEGMENTS.has(seg)));
396
+ return nested
397
+ ? { kind: "PASS", detail: nested.split("/").slice(0, -1).join("/") }
360
398
  : { kind: "FAIL", detail: "database in use, no migrations directory" };
361
399
  },
362
400
  },
@@ -388,7 +426,7 @@ function makeChecks(root, ctx) {
388
426
  run: () => {
389
427
  if (config && config.modules && config.modules.design === false)
390
428
  return { kind: "SKIP", detail: "design module disabled" };
391
- if (!pkg) return { kind: "SKIP", detail: "no package.json" };
429
+ if (!pkg) return { kind: "SKIP", detail: noManifest };
392
430
  const found = hasDep(
393
431
  "axe-core", "@axe-core/react", "@axe-core/playwright", "jest-axe",
394
432
  "eslint-plugin-jsx-a11y", "pa11y", "@storybook/addon-a11y", "lighthouse"
@@ -407,7 +445,7 @@ function makeChecks(root, ctx) {
407
445
  const profile = config?.project?.profile;
408
446
  if (["cli", "library", "docs"].includes(profile))
409
447
  return { kind: "SKIP", detail: `${profile} does not run as a watched service` };
410
- if (!pkg) return { kind: "SKIP", detail: "no package.json" };
448
+ if (!pkg) return { kind: "SKIP", detail: noManifest };
411
449
  const found = hasDep("@sentry/node", "@sentry/browser", "@sentry/nextjs", "bugsnag",
412
450
  "rollbar", "datadog-lambda-js", "dd-trace", "@opentelemetry/api");
413
451
  return found
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { packageRoot, exists, read, writeFile, writeJSON, walk } from "../lib/fs.mjs";
4
+ import { trackedFiles } from "../lib/git.mjs";
4
5
  import { cliVersion, standardVersion } from "../lib/version.mjs";
5
6
  import { vendorStandard } from "../lib/vendor.mjs";
6
7
  import { color, heading, status, line } from "../lib/ui.mjs";
@@ -15,10 +16,22 @@ const PROFILES = {
15
16
  docs: "Documentation or content repository",
16
17
  };
17
18
 
19
+ /**
20
+ * Every package.json in the repository, nearest to the root first. A monorepo keeps its real
21
+ * manifest in `apps/web/` or `packages/*`, and a profile detected from the root alone falls back
22
+ * to a default while the evidence sits one directory down.
23
+ */
24
+ function findManifests(root) {
25
+ const tracked = trackedFiles(root);
26
+ if (!tracked) return exists(path.join(root, "package.json")) ? ["package.json"] : [];
27
+ return tracked
28
+ .filter((f) => /(^|\/)package\.json$/.test(f) && !f.includes("node_modules/"))
29
+ .sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
30
+ }
31
+
18
32
  function detectProfile(root) {
19
- const pkg = path.join(root, "package.json");
20
- if (exists(pkg)) {
21
- const json = JSON.parse(read(pkg) || "{}");
33
+ for (const manifest of findManifests(root)) {
34
+ const json = JSON.parse(read(path.join(root, manifest)) || "{}");
22
35
  const deps = { ...json.dependencies, ...json.devDependencies };
23
36
  if (json.bin) return "cli";
24
37
  if (deps.next || deps.react || deps.vue || deps.svelte || deps["@angular/core"])
@@ -46,8 +59,14 @@ function detectName(root) {
46
59
  }
47
60
 
48
61
  function detectCodePaths(root) {
49
- const candidates = ["src", "app", "lib", "packages", "server", "api", "web", "components"];
50
- return candidates.filter((c) => exists(path.join(root, c)));
62
+ const candidates = ["src", "app", "apps", "lib", "packages", "server", "api", "web", "components"];
63
+ const found = candidates.filter((c) => exists(path.join(root, c)));
64
+ // The directory holding a manifest is code by definition, wherever it sits.
65
+ for (const manifest of findManifests(root)) {
66
+ const dir = path.posix.dirname(manifest);
67
+ if (dir !== "." && !found.some((f) => dir === f || dir.startsWith(`${f}/`))) found.push(dir);
68
+ }
69
+ return found;
51
70
  }
52
71
 
53
72
  function fill(text, vars) {