@venizia/vulcan 0.1.0-0 → 0.1.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.
package/README.md CHANGED
@@ -46,7 +46,7 @@ An unknown key is an error; every error names the file and the field.
46
46
 
47
47
  ## What ships (`base/`)
48
48
 
49
- - `rules.md` - the base rules: write boundaries, security, process, build, and the IGNIS coding style V-C-01..V-C-18.
49
+ - `rules.md` - the base rules: write boundaries, security, process, build, and the IGNIS coding style V-C-01..V-C-18 (37 rules; V-W-05, V-C-03 and V-C-05 are retired).
50
50
  - `slots.json` - rules a project picks a wording for (V-W-01 today).
51
51
  - `skills/` - `knowledge-sync`, `docs-writing` (project-neutral; a project-specific skill stays in its project).
52
52
  - `hooks/` - `session-start.ts` (prints the rules digest) and `guard-writes.ts` (asks before a database write, a migration, a deploy or a mutating HTTP call).
package/base/rules.md CHANGED
@@ -2,12 +2,16 @@
2
2
 
3
3
  <!-- Shipped by @venizia/vulcan. Never edit in a project: add extensions in .agents/rules.md.
4
4
  W, S, P, B are INTERIM until the base-rules work reconciles the rules IGNIS, ARDOR and BANA share.
5
- C is IGNIS's coding style, V-C-01..V-C-18 verbatim from IGNIS (only IGNIS-only literals generalized), shipped to every project. -->
5
+ C is IGNIS's coding style, shipped to every project; the rules that bind IGNIS's own stack live in IGNIS. -->
6
6
 
7
7
  Cite by ID. Every rule has "Enforced by". Base IDs carry the `V-` namespace (`V-W-01`, `V-C-04`).
8
8
  A project extends this set in its own `.agents/rules.md` with bare IDs (`W-06`, `C-19`), which can
9
9
  never collide with a base ID; it never claims the `V-` namespace (`vulcan doctor` enforces it).
10
10
 
11
+ Retired IDs are never reused: `V-W-05` (task-manager loop), `V-C-03` (`@inject` on container-built
12
+ classes) and `V-C-05` (`getError` / `ApplicationError`) - the last two, with the hard stack once in
13
+ `V-C-18`, are IGNIS rules now (2026-10-17).
14
+
11
15
  ## W - Write boundaries
12
16
 
13
17
  | ID | Rule | Enforced by |
@@ -16,7 +20,6 @@ never collide with a base ID; it never claims the `V-` namespace (`vulcan doctor
16
20
  | V-W-02 | Never write to a database. Write the migration file, hand over. Read-only SELECT ok. | HARD - guard-writes hook (asks) |
17
21
  | V-W-03 | Never deploy or hot-apply (kubectl apply, docker compose up, restarts). | HARD - guard-writes hook (asks) |
18
22
  | V-W-04 | Ask before anything irreversible: deleting files you didn't create, side-effect APIs, publishing. | SOFT |
19
- | V-W-05 | Task-manager writes limited to the workflow loop. Bulk edits/deletions need preview + approval. | SOFT |
20
23
 
21
24
  ## S - Security
22
25
 
@@ -35,7 +38,7 @@ never collide with a base ID; it never claims the `V-` namespace (`vulcan doctor
35
38
  | ID | Rule | Enforced by |
36
39
  |----|------|-------------|
37
40
  | V-P-01 | Read `AGENTS.md`, these base rules, the project's `.agents/rules.md` and the relevant `.agents/knowledge/` before acting. | SOFT - session-start hook |
38
- | V-P-02 | New feature: no code until PRD reaches `gate: planned`. | SOFT |
41
+ | V-P-02 | **Design the surface before the body.** For a feature, propose what its users see first - signatures, types, options, config, output - and get a yes before implementing. Priorities, in order: simplicity > flexibility > completeness. | SOFT |
39
42
  | V-P-03 | Plans need human approval before implementation. | SOFT |
40
43
  | V-P-04 | Fact changes → update `.agents/knowledge/` in the same change. | SOFT |
41
44
  | V-P-05 | Partner, not stenographer. Wrong instruction → say so with reasoning + better option; human decides. Never overrides W rules or V-P-03. | SOFT |
@@ -56,9 +59,7 @@ never collide with a base ID; it never claims the `V-` namespace (`vulcan doctor
56
59
  |----|------|-------------|
57
60
  | V-C-01 | **English only in code** - identifiers, comments, commit messages, logs, docs. Vietnamese belongs in chat. | SOFT |
58
61
  | V-C-02 | **Options objects everywhere:** `fn({ key, value })`, never `fn(key, value)`. **One parameter is not an exception** - `isValidName({ name })`, not `isValidName(name)`. A single positional parameter reads fine until the second one arrives, and by then the method is published and every call site pays. The cost is highest on an interface others implement: `IStorageHelper` carried three positional methods for exactly this reason and the migration touched 72 call sites. Mixing the two styles in one interface is itself the defect - a reader cannot tell which shape a method takes without opening it. | SOFT |
59
- | V-C-03 | **Every constructor parameter of a container-instantiated class carries `@inject`.** Mixing decorated and undecorated parameters is refused at boot - the container has no channel to supply an undecorated one. Options a controller needs go in `super({ scope: X.name })`, never as an undecorated parameter. | SOFT |
60
62
  | V-C-04 | **Never abbreviate identifiers:** `ProductRepository` not `ProductRepo`, `ProductDocument` not `ProductDoc`; type parameters too (`TDocument`, not `TDoc`). A project lists its own exceptions in its rules (IGNIS: `tz` for a time zone). | SOFT |
61
- | V-C-05 | **Errors are `getError` / `ApplicationError`. Never `new Error`.** Never `instanceof ApplicationError` across a package boundary - the dual CJS+ESM build gives the class several identities; use `isApplicationError()`. | SOFT |
62
63
  | V-C-06 | **Never a silent catch - log it.** Always braces. Early return. `switch` with `default` over a long if-else chain. Strict TypeScript; no `any` where a type exists. | LINT (braces, floating promises) + SOFT |
63
64
  | V-C-07 | **A function name opens with its verb** - `generate` `build` `to` `is` `has` `assert` `extract` `enrich` `get` `resolve`. `is*` returns a boolean and leaves the branch to the caller; `assert*` throws and returns `void`, so the code after it needs none - reach for `assert*` when every caller would throw on `false` anyway. `has*` is the ownership question (`hasBucket`, never `isBucketExists`). That list is for UTILITY functions; service methods lean on a wider set. **Arrow functions, not `function` declarations** (do not churn existing ones). State plus the operations on it live in a class with static members - the `LoggerFactory` shape - never module-level `let` plus exported arrows. | SOFT |
64
65
  | V-C-08 | **Enumerable strings are a const class plus `TConstValue`,** never a raw string-literal union. Every such class carries `static readonly SCHEME_SET = new Set([...])` over its values and `static isValid(value: string): boolean` reading it, so a runtime check exists wherever a value crosses a boundary (`IsolationLevels` in connectors is the model; a subclass redeclares `SCHEME_SET` with `...Parent.SCHEME_SET` first). The `T`/`I` prefix follows the declaration keyword: `type` gets `T`, `interface` gets `I`; an interface turned into a type alias is renamed, and no tool catches it - grep both directions. | SOFT |
@@ -71,4 +72,4 @@ never collide with a base ID; it never claims the `V-` namespace (`vulcan doctor
71
72
  | V-C-15 | **One method with a mode option, not several specialized methods.** Payload variants are zod-per-variant with `z.infer`, discriminated. | SOFT |
72
73
  | V-C-16 | **One concept, one implementation.** When the same question is answered in several places, the bug lives at the seam and no tool catches it. Consolidate to one home before building on top. | SOFT |
73
74
  | V-C-17 | **A file is named by its role, never by repeating its folder:** `winston/logger.ts`, not `winston/winston-logger.ts`. | SOFT |
74
- | V-C-18 | **The hard stack, and derived types over duplicates.** Drizzle only (never TypeORM, Prisma, Sequelize); Hono only (never Express, Fastify, Koa); Zod only (never Joi, Yup, class-validator); PostgreSQL primary with SQLite alongside, the repository tier engine-neutral (`pgTable` and `sqliteTable` both). REST is default framework behavior, not a component. Prefer compile-time types derived from definitions (`typeof User.schema`) over hand-maintained copies. | SOFT |
75
+ | V-C-18 | **Derived types over duplicates.** Prefer compile-time types derived from their definitions (`typeof schema`, `z.infer<typeof Schema>`, `ReturnType<typeof build>`) over hand-maintained copies - a copy drifts the day its source changes. A project's stack (framework, ORM, validator) is a project rule, not a base rule. | SOFT |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venizia/vulcan",
3
- "version": "0.1.0-0",
3
+ "version": "0.1.0-1",
4
4
  "description": "VULCAN - one setup, one workflow for every project: init, sync, doctor, upgrade, and `vulcan x` for the shipped tools. Bun-only.",
5
5
  "homepage": "https://github.com/VENIZIA-AI/vulcan/tree/main/packages/cli",
6
6
  "repository": {
@@ -44,8 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@venizia/ignis-helpers": "^0.2.2-5",
47
- "@venizia/vulcan-atlas": "0.1.0-0",
48
- "@venizia/vulcan-okf": "0.1.0-0",
47
+ "@venizia/vulcan-atlas": "0.1.0-1",
48
+ "@venizia/vulcan-okf": "0.1.0-1",
49
49
  "zod": "^4.5.4"
50
50
  },
51
51
  "devDependencies": {
@@ -1,3 +1,4 @@
1
+ import { Glob } from 'bun';
1
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
3
  import { join, relative } from 'node:path';
3
4
  import { BASE_DIRECTORY, ProjectLayout } from '../common/index.ts';
@@ -6,7 +7,7 @@ import { parseRuleIds, readSlots, RuleConstants } from '../rules/index.ts';
6
7
  import { planSync, SyncStatuses } from '../sync/index.ts';
7
8
  import { DoctorChecks, DoctorConstants } from './common/index.ts';
8
9
  import type { IViolation, TDoctorCheck } from './common/index.ts';
9
- import { parseMakeTargets } from './makefile.ts';
10
+ import { parseMakeIncludes, parseMakeTargets } from './makefile.ts';
10
11
 
11
12
  export interface ICheckContext {
12
13
  root: string;
@@ -119,6 +120,35 @@ export const checkSkills = (opts: ICheckContext): IViolation[] => {
119
120
  );
120
121
  };
121
122
 
123
+ /**
124
+ * The project's Makefile and every file it includes, recursively, relative to the root. Includes
125
+ * under node_modules are shipped (vulcan.mk), never project targets.
126
+ */
127
+ const findProjectMakefiles = (opts: { root: string }): string[] => {
128
+ const found: string[] = [];
129
+ const pending = ['Makefile'];
130
+
131
+ while (pending.length > 0) {
132
+ const file = pending.shift() ?? '';
133
+
134
+ if (found.includes(file) || !existsSync(join(opts.root, file))) {
135
+ continue;
136
+ }
137
+
138
+ found.push(file);
139
+
140
+ for (const pattern of parseMakeIncludes({
141
+ makefile: readText({ file: join(opts.root, file) }),
142
+ })) {
143
+ const matches = [...new Glob(pattern).scanSync({ cwd: opts.root, onlyFiles: true })].sort();
144
+
145
+ pending.push(...matches.filter(match => !match.split('/').includes('node_modules')));
146
+ }
147
+ }
148
+
149
+ return found;
150
+ };
151
+
122
152
  export const checkMake = (opts: ICheckContext): IViolation[] => {
123
153
  const makefile = readText({ file: join(opts.root, 'Makefile') });
124
154
  const violations: IViolation[] = [];
@@ -137,14 +167,18 @@ export const checkMake = (opts: ICheckContext): IViolation[] => {
137
167
  parseMakeTargets({ makefile: readText({ file: join(BASE_DIRECTORY, 'make', 'vulcan.mk') }) }),
138
168
  );
139
169
 
140
- for (const target of parseMakeTargets({ makefile }).filter(name => shipped.has(name))) {
141
- violations.push(
142
- createViolation({
143
- check: DoctorChecks.MAKE,
144
- file: 'Makefile',
145
- message: `'${target}' is a shipped target - give the project target another name`,
146
- }),
147
- );
170
+ for (const file of findProjectMakefiles({ root: opts.root })) {
171
+ const targets = parseMakeTargets({ makefile: readText({ file: join(opts.root, file) }) });
172
+
173
+ for (const target of targets.filter(name => shipped.has(name))) {
174
+ violations.push(
175
+ createViolation({
176
+ check: DoctorChecks.MAKE,
177
+ file,
178
+ message: `'${target}' is a shipped target - give the project target another name`,
179
+ }),
180
+ );
181
+ }
148
182
  }
149
183
 
150
184
  return violations;
@@ -6,3 +6,12 @@ export const parseMakeTargets = (opts: { makefile: string }): string[] =>
6
6
  .split('\n')
7
7
  .map(line => RULE_TARGET.exec(line)?.[1])
8
8
  .filter((target): target is string => target !== undefined);
9
+
10
+ const INCLUDE_LINE = /^\s*(?:-include|sinclude|include)\s+(.+?)\s*$/;
11
+
12
+ /** The paths and globs an `include`, `-include` or `sinclude` line names; one built from a variable is skipped. */
13
+ export const parseMakeIncludes = (opts: { makefile: string }): string[] =>
14
+ opts.makefile
15
+ .split('\n')
16
+ .flatMap(line => INCLUDE_LINE.exec(line)?.[1].split(/\s+/) ?? [])
17
+ .filter(path => !path.includes('$('));
@@ -19,6 +19,7 @@ import type { ISyncAction, TSettings } from './common/index.ts';
19
19
  import {
20
20
  buildProjectHookSettings,
21
21
  isSettingsObject,
22
+ keepKeyOrder,
22
23
  mergeSettings,
23
24
  removeManagedSettings,
24
25
  } from './settings.ts';
@@ -118,11 +119,13 @@ const planSettings = (opts: { root: string; config: TResolvedConfig }): ISyncAct
118
119
 
119
120
  const previous = readSettingsFile({ path: managedPath }) ?? {};
120
121
  const overlay = buildManagedOverlay({ config: opts.config });
121
- const wanted = mergeSettings({
122
+ const merged = mergeSettings({
122
123
  base: removeManagedSettings({ settings: current, managed: previous }),
123
124
  overlay,
124
125
  });
125
126
 
127
+ const wanted = keepKeyOrder({ settings: merged, reference: current });
128
+
126
129
  const text = `${JSON.stringify(wanted, null, 2)}\n`;
127
130
  const managedText = `${JSON.stringify(overlay, null, 2)}\n`;
128
131
  const before = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
@@ -48,6 +48,37 @@ export const mergeSettings = (opts: { base: TSettings; overlay: TSettings }): TS
48
48
 
49
49
  const toKey = (opts: { value: unknown }): string => JSON.stringify(opts.value);
50
50
 
51
+ /** `settings` with its keys in `reference`'s order: keys `reference` has keep their place, new keys follow. */
52
+ const orderKeysLike = (opts: { settings: TSettings; reference: TSettings }): TSettings => {
53
+ const keys = [
54
+ ...Object.keys(opts.reference).filter(key => key in opts.settings),
55
+ ...Object.keys(opts.settings).filter(key => !(key in opts.reference)),
56
+ ];
57
+
58
+ return Object.fromEntries(keys.map(key => [key, opts.settings[key]]));
59
+ };
60
+
61
+ /**
62
+ * `settings` in the key order the user's file already has, here and one level down: a sync that only
63
+ * reorders keys rewrites the file, and `doctor` then calls a fresh sync stale.
64
+ */
65
+ export const keepKeyOrder = (opts: { settings: TSettings; reference: TSettings }): TSettings => {
66
+ const ordered = orderKeysLike(opts);
67
+
68
+ for (const key of Object.keys(ordered).filter(entry =>
69
+ SyncConstants.MERGE_ONE_LEVEL.has(entry),
70
+ )) {
71
+ const value = { value: ordered[key] };
72
+ const previous = { value: opts.reference[key] };
73
+
74
+ if (isSettingsObject(value) && isSettingsObject(previous)) {
75
+ ordered[key] = orderKeysLike({ settings: value.value, reference: previous.value });
76
+ }
77
+ }
78
+
79
+ return ordered;
80
+ };
81
+
51
82
  /** `settings` without what `managed` (the overlay of a previous sync) put there; anything else stays. */
52
83
  export const removeManagedSettings = (opts: {
53
84
  settings: TSettings;