@afokapu/atdd-bun 0.9.1 → 0.9.2

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
@@ -52,7 +52,7 @@ registerEnforcementTest({ root: import.meta.dir + "/..", profiles: ["traceabilit
52
52
  | Profile | Checks |
53
53
  |---|---|
54
54
  | `traceability` | acceptance → Bun test → source closure: every acceptance tested, every binding and `Tested-By` resolving |
55
- | `topology` | feature decomposition and the plan, source, test and E2E locations |
55
+ | `topology` | feature decomposition and the plan, source, test and E2E locations. Missing source, tests and E2E suites are reported only when a run also selects `coder` or `tester`, so a repository in the PLAN stage can enforce `planner` and `topology` before RED |
56
56
  | `planner` | schemas for every plan artifact, graph integrity, the scoped planner rules |
57
57
  | `telemetry` | the telemetry tracking plan: item shape, path-mirrored identity and versioning under `telemetry/`, wagon ownership of logical artifacts, the per-acceptance telemetry decision, metric label cardinality, source `Telemetry:` references, raw-string and forbidden-property emission, the vendor-SDK boundary around the TelemetryPort, and telemetry tests that bind the acceptance and item, assert the exact identity on a captured sink, cover every required item, and exercise declared timing semantics |
58
58
  | `delivery` | the review record of each tranche under `docs/delivery/tranches/`: allowed author and reviewer models with recorded fallbacks, reviewer independence, every finding fixed, withdrawn after one dispute or ruled on by a human, every configured stage approved, and, at the gate, no change without a record and a merged head that contains exactly the approved commit. Inert until adopted |
@@ -173,7 +173,9 @@ behind); it does not verify them
173
173
  cryptographically.
174
174
 
175
175
  The hooks enforce protected-branch blocking, micro-commit limits, mass-delete approval and
176
- validation of the affected area. Git can bypass them, so CI is the authority.
176
+ validation of the affected area. Git can bypass them, so CI is the authority. The changed-line
177
+ limit counts a moved file by the edits it carries, so relocating a directory is not measured as
178
+ rewriting it; mass-delete and registry-removal approval still count a move in full.
177
179
 
178
180
  ## Agents and integrity
179
181
 
@@ -42,6 +42,8 @@ content:
42
42
  calls, and train.sequence executor loops from JourneyRunner.
43
43
  exceptions:
44
44
  - Consumers with no plan/_journeys topology carry no JourneyRunner obligation.
45
+ - Consumers with no Station Master composition root yet (a repository in the PLAN stage) carry no
46
+ JourneyRunner obligation, as coder.bun.interlocking-runner-exists owes no InterlockingRunner before one.
45
47
  metadata:
46
48
  aliases:
47
49
  - BUN-JOURNEY-RUNNER-BOUNDARY-001
@@ -36,6 +36,8 @@ content:
36
36
  declared journey document and dispatch it through JourneyRunner.
37
37
  exceptions:
38
38
  - Journeys with entrypoint.exposed:false are intentionally not Station-Master-reachable.
39
+ - Consumers with no Station Master composition root yet (a repository in the PLAN stage) carry no
40
+ reachability obligation, as for exposed interlockings.
39
41
  metadata:
40
42
  aliases:
41
43
  - BUN-STATION-MASTER-JOURNEY-ROUTING-001
@@ -12,6 +12,12 @@ const slug = "[a-z][a-z0-9-]*";
12
12
  const componentUrn = new RegExp(`^component:(${slug}):(${slug}):[A-Za-z0-9.]+:(frontend|backend):(domain|application|integration|presentation)$`);
13
13
  const testUrn = new RegExp(`^test:(${slug}):(${slug}):([A-Za-z0-9][A-Za-z0-9._-]*)$`);
14
14
  const violations = [];
15
+ // Missing evidence is due only when a run asks for the stages that produce it. A planning run (planner or
16
+ // topology alone) still checks where every artifact lives and how every existing test binds, but it does not
17
+ // demand source, tests or E2E suites the lifecycle has not reached: RED writes the tests and GREEN the source.
18
+ // A run that names no profiles (a direct call) keeps the old, fail-closed behaviour.
19
+ const requestedProfiles = process.env.ATDD_PROFILES ? JSON.parse(process.env.ATDD_PROFILES) : null;
20
+ const evidenceDue = !Array.isArray(requestedProfiles) || requestedProfiles.some(profile => ["coder", "tester", "all"].includes(profile));
15
21
  const rel = (root, path) => relative(root, path).split(sep).join("/");
16
22
  const read = path => { try { return readFileSync(path, "utf8"); } catch { return ""; } };
17
23
  const text = (data, key) => data && typeof data === "object" && !Array.isArray(data) && typeof data[key] === "string" ? data[key] : "";
@@ -104,8 +110,8 @@ for (const root of roots) {
104
110
  }
105
111
  for (const feature of features) if (list(feature.data, "wmbts").length) {
106
112
  const key = `${feature.wagon}:${feature.slug}`;
107
- if (!sourceByFeature.get(key)?.length) add("atdd-bun.topology.feature-source-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no source component beneath ${cfg.source_root}/${feature.wagon}/features/${feature.slug}/`);
108
- if (!testByFeature.get(key)?.length) add("atdd-bun.topology.feature-test-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no test bound to one of its acceptances`);
113
+ if (evidenceDue && !sourceByFeature.get(key)?.length) add("atdd-bun.topology.feature-source-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no source component beneath ${cfg.source_root}/${feature.wagon}/features/${feature.slug}/`);
114
+ if (evidenceDue && !testByFeature.get(key)?.length) add("atdd-bun.topology.feature-test-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no test bound to one of its acceptances`);
109
115
  }
110
116
  const e2eRoot = join(root, cfg.e2e_root);
111
117
  // A browser test is the E2E proof for a frontend behavior. It deliberately
@@ -148,7 +154,7 @@ for (const root of roots) {
148
154
  const expected = join(e2eRoot, "interlockings", id, `${routeId}.routes.test.ts`);
149
155
  interlockingRoutes.add(rel(root, expected));
150
156
  const browserEligible = frontendInterlockings.has(text(interlocking.data, "interlocking_id"));
151
- if (!existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
157
+ if (evidenceDue && !existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
152
158
  ? `${text(interlocking.data, "interlocking_id")} route ${routeId} requires ${rel(root, expected)} or a Playwright E2E spec bound to Train: ${text(route, "train_id")}`
153
159
  : `${text(interlocking.data, "interlocking_id")} route ${routeId} is not on an exposed frontend journey and requires ${rel(root, expected)}`);
154
160
  }
@@ -157,7 +163,7 @@ for (const root of roots) {
157
163
  const entry = journey.data.entrypoint, id = text(journey.data, "journey_id").slice(8), path = join(e2eRoot, "journeys", `${id}.journey.test.ts`);
158
164
  const reachable = journeyReachability(journey), journeyTrains = new Set([...reachable].flatMap(interlockingId => list(interlockings.get(interlockingId)?.data, "routes").map(route => text(route, "train_id")).filter(Boolean)));
159
165
  const browserEligible = entry && typeof entry === "object" && hasFrontendSurface(entry);
160
- if (entry && typeof entry === "object" && entry.exposed === true && !existsSync(path) && !(browserEligible && browserJourneys.has(`journey:${id}`))) add("atdd-bun.topology.e2e-location", root, journey.path, browserEligible
166
+ if (evidenceDue && entry && typeof entry === "object" && entry.exposed === true && !existsSync(path) && !(browserEligible && browserJourneys.has(`journey:${id}`))) add("atdd-bun.topology.e2e-location", root, journey.path, browserEligible
161
167
  ? `exposed journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts or a Playwright E2E spec bound to Journey: journey:${id}`
162
168
  : `exposed backend journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts`);
163
169
  else if (entry && typeof entry === "object" && entry.exposed === true && existsSync(path)) {
@@ -6,6 +6,7 @@ import {
6
6
  parseJsonEnv,
7
7
  readText,
8
8
  findConsumerRoots,
9
+ appFile,
9
10
  runtimeFiles,
10
11
  referencesToken,
11
12
  importsWagon,
@@ -87,6 +88,8 @@ for (const scanRoot of roots) {
87
88
  .filter(item => /\bclass\s+JourneyRunner\b/.test(maskComments(item.text)));
88
89
 
89
90
  if (modules.length === 0) {
91
+ // As interlocking-runner-exists: the runner is owed once a Station Master exists to call it, not before.
92
+ if (!appFile(croot)) continue;
90
93
  violations.push(mk(
91
94
  RULE,
92
95
  `${PLAN_ROOT}/_journeys`,
@@ -62,17 +62,9 @@ for (const scanRoot of roots) {
62
62
  if (journeys.length === 0) continue;
63
63
 
64
64
  const app = appFile(croot);
65
- if (!app) {
66
- for (const journey of journeys) violations.push(mk(
67
- RULE,
68
- journey.file,
69
- 1,
70
- 0,
71
- "journey-station-missing: exposed journey has no server.ts Station Master composition root",
72
- "",
73
- ));
74
- continue;
75
- }
65
+ // As for interlockings: with no Station Master composition root yet (a repository in the PLAN stage),
66
+ // there is nothing to route through, so reachability is not yet due.
67
+ if (!app) continue;
76
68
 
77
69
  const text = readText(app);
78
70
  const appPath = relative(croot, app).replaceAll("\\", "/");
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.9.1",
2
+ "version": "0.9.2",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "bd4749f81d83b92e0154692f3ee8e926b0c05ba3fbb41b430da03d56d72d7508",
6
+ "README.md": "efc3f9f4c8cf13a62b6f17d2a41a17d871d2b14c551617a8b49a832edcb77d04",
7
7
  "bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
8
8
  "conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml": "81c5c773d5ee15e8d99a2c237aa9846b110b2533df45ebf407d0e3ec6ed3dd03",
9
9
  "conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml": "e96d7c1455d0c221072d82e7f2b55da1c4f9ceb17718eb6ed96affdeef37675b",
@@ -60,7 +60,7 @@
60
60
  "conventions/coder.bun/coder.bun.interlocking-does-not-carry-cargo.convention.yaml": "c8f906c4aa6709aab7dc91e719331fd1865c229708acd9838373712a777138b1",
61
61
  "conventions/coder.bun/coder.bun.interlocking-resolution-model-exists.convention.yaml": "dccd0fa99686811d2befc32861f995635b3e194004865710d5b9064a3feea8a8",
62
62
  "conventions/coder.bun/coder.bun.interlocking-runner-exists.convention.yaml": "f7fadc580d2896f7d8341f555e4447480809329f3901209d30c2e54181032f15",
63
- "conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml": "e62d541d8932c0fba6a85d7a0fa8155eba53b41e2e35f8b52c720f840c2263a6",
63
+ "conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml": "51df6caa810d887a84fc8196bc103465685400f12ba8a0504efef9b88c10fe33",
64
64
  "conventions/coder.bun/coder.bun.layer-naming.convention.yaml": "009c21f6adbbc1e08d9781d7466b06e3d7fb7d935bf45b1df07fe37639a60a82",
65
65
  "conventions/coder.bun/coder.bun.logging-console.convention.yaml": "b0e5765b8efba245cdd133f8321ba2364acbfae26a302f09e25cc905d652ae28",
66
66
  "conventions/coder.bun/coder.bun.logging-structured.convention.yaml": "a7c8bd90762bf1516d2af63d4fe731720bec9f58431009f69af1bfebc0398759",
@@ -77,7 +77,7 @@
77
77
  "conventions/coder.bun/coder.bun.security-missing-auth.convention.yaml": "2b937475e7a1f3f2718be6a3f0d94fa261ca65e72bbf8e3f2ca39ba143b90544",
78
78
  "conventions/coder.bun/coder.bun.security-sql-injection.convention.yaml": "b4cbc3a446bbbf874762fd13c873305eaf7417c45a6206133163509b97267faa",
79
79
  "conventions/coder.bun/coder.bun.station-master-interlocking-routing.convention.yaml": "b0b0572a79b4dd6f35db350980652afbdd797646d613c552b7c5595f72efdee8",
80
- "conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml": "5f0c26b860c85ae5a22f3d63dd0a243b7cdda39812dc291591ecc5fe75a894a7",
80
+ "conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml": "f03cb85e1a86d4811aa0a0e7e211900e482d16d168d5fd05d128a3e5cda4648e",
81
81
  "conventions/coder.bun/coder.bun.telemetry-forbidden-properties.convention.yaml": "7dd411af2180e70a84f4bad99c568060504bb5ff5e7ccb9fc1b6fd4b3aadcbcd",
82
82
  "conventions/coder.bun/coder.bun.telemetry-implementation-binding.convention.yaml": "5eb854948cdb8a1510fe808e3e5be13840d02b6bf07b4aa6d6ca94739128e7a7",
83
83
  "conventions/coder.bun/coder.bun.telemetry-raw-string-emit.convention.yaml": "7fb7c83a916078184862c89edf1bf3bc6332ffd6bc8e2f17d96a221af1a40007",
@@ -161,7 +161,7 @@
161
161
  "conventions/traceability/traceability.source.tested-by-resolves.convention.yaml": "ad4516670934dc82f1ebf9dc057110313059110302f88b4747dde89dd060870b",
162
162
  "conventions/traceability/traceability.test.binding-resolves.convention.yaml": "03c118f498b1e824d457d25c060c8f9689ccbcca49c58d8bcd158e3cd3144ee9",
163
163
  "detectors/atdd_topology/atdd.implementation.yaml": "7879f441a62691bf041b0fd8220feb12996d4a036d74d6c79d0985ee20992efb",
164
- "detectors/atdd_topology/detect.mjs": "33cb5584ce334352324e4a8331f75697cc9c09f7ebc07e59c5f917ad9879f447",
164
+ "detectors/atdd_topology/detect.mjs": "ae03378b2354c794fc865ff66436abbdf11aacff29cef9284e4f90648dcbc6f9",
165
165
  "detectors/atdd_topology/fixtures/clean/atdd-bun.yaml": "85049c05fb624037e1e2200a117b07d6c8d089d4e81fd784ade15a860a444f59",
166
166
  "detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts": "be769e192e8165d4d36be743e8df578c1790b900c6b89acc7d2046ba40105135",
167
167
  "detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts": "345c391888e3e8c93d0437428dc1a3358968c7871ef54d313d6e083717254bbe",
@@ -431,8 +431,8 @@
431
431
  "detectors/bun_interlocking_infrastructure/checks/interlocking_resolution_model.mjs": "e761495b19eed0af09fcb4db34126d0e2398e91f086e04f5ef84d0b5c1c3ec16",
432
432
  "detectors/bun_interlocking_infrastructure/checks/interlocking_runner_exists.mjs": "6b63d3f26a51f2dd27847edd27c4d9c9f15c1f02747dba611cebccfc76f13169",
433
433
  "detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs": "0221f5a8b49763388affefff670bc2a452e539e7658b08f6230a52f13edd36bd",
434
- "detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "8c7225f5ee387cbf7ba1e536f91c67c331a86025b4b2b38b961488d949cbdb21",
435
- "detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "b297f3f677e0239c5d14cf36218fc39ec23024a0f29620dd02945601aad20516",
434
+ "detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "570e4aef423881aed982b8f25a8d553f939d1ab82271236525c14efe3a20cd4a",
435
+ "detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "bfd4899a604c34de621475ce155e527b9281d1174c6daa13a2abc7bebabf2cde",
436
436
  "detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs": "728f710827295472dadec182fcdf6c3e8fc44cdedeae53a669754be8435fdd7f",
437
437
  "detectors/bun_interlocking_infrastructure/detect.mjs": "54c02f428596e057f828def0d68360b220b55dbf962b46694a21bc423ce57218",
438
438
  "detectors/bun_interlocking_infrastructure/fixtures/clean/interlocking_delegates/server.ts": "36913357fdd3c5e375829a0554c605d70f5c05e2e83349f47505741598f6eb15",
@@ -1192,8 +1192,8 @@
1192
1192
  "src/cli.ts": "bb2047cee60f48bfe3eaee4c1b3dcd3538a0e1edc170cf58e87686117bea07a7",
1193
1193
  "src/delivery.ts": "8c2f964a4acc4471b6541f980c9c6f270b33cd64c95f5fe852a401060d507975",
1194
1194
  "src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
1195
- "src/enforce.ts": "58ad0593895912bdc1effe97430b07acf8cda066052397957558cbb331c16778",
1196
- "src/hooks.ts": "adbb72f7a83f53d596fd217f01f4ce32c5a59a23ab81993fed226fb96fd1cbd1",
1195
+ "src/enforce.ts": "41a6f8c058a2a034996c817f224191403cc66ccc4ed4e04bbefc430014c783d8",
1196
+ "src/hooks.ts": "bafda87a1cb1352133ec7e69712cd8b7acd7767dd6c87ad072f1b5596baaee0e",
1197
1197
  "src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
1198
1198
  "src/integrity.ts": "ac516b1d3a73f872d0ed2b20a27ef0ac14ecc015293bd8e51954f322f8a063bb",
1199
1199
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/enforce.ts CHANGED
@@ -101,7 +101,7 @@ export async function undeclaredEmissions(implementation: string, violations: Pi
101
101
 
102
102
  export async function runImplementation(
103
103
  implementation: string,
104
- config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes">>,
104
+ config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes">> & { profiles?: ConcreteProfile[] },
105
105
  ): Promise<Violation[]> {
106
106
  const detector = join(detectorRoot, implementation, "detect.mjs");
107
107
  const scratch = await mkdtemp(join(tmpdir(), "atdd-bun-"));
@@ -119,6 +119,9 @@ export async function runImplementation(
119
119
  ATDD_SCAN_EXCLUDES: JSON.stringify(config.excludes),
120
120
  ATDD_PLAN_ROOT: topology.planRoot,
121
121
  ATDD_VIOLATIONS_REPORT: report,
122
+ // The profiles this run selected, so a detector shared by several profiles can tell a planning run from
123
+ // one that has reached RED or GREEN. Absent on a direct call, where detectors keep their full behaviour.
124
+ ...(config.profiles ? { ATDD_PROFILES: JSON.stringify(config.profiles) } : {}),
122
125
  },
123
126
  });
124
127
  const exitCode = await child.exited;
@@ -147,7 +150,7 @@ export async function enforce(config: EnforcementConfig = {}): Promise<Violation
147
150
  const requested = config.profiles ?? ["all"], enabled = await enabledProfiles(root);
148
151
  const selected = requested.includes("all") ? [...new Set([...requested.filter(p => p !== "all"), ...enabled])] : requested;
149
152
  const results = await Promise.all(
150
- implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes })),
153
+ implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes, profiles: selected as ConcreteProfile[] })),
151
154
  );
152
155
  return results.flat().sort((left, right) =>
153
156
  left.rule_id.localeCompare(right.rule_id) || left.file.localeCompare(right.file) || left.line - right.line,
package/src/hooks.ts CHANGED
@@ -16,6 +16,10 @@ const files = async (root: string, args: string[]) => (await git(root, args)).ou
16
16
  const strings = (value: unknown) => Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined;
17
17
  // Declarative registries are exempt from the micro-commit size caps but never from validation or removal approval.
18
18
  const isRegistry = (cfg: HookPolicy, path: string) => cfg.registry_paths.some(pattern => new Bun.Glob(pattern).match(path));
19
+ /** The path a rename row of `git diff --numstat -M` lands on: `a => b`, or `dir/{a => b}/file`. */
20
+ const renamedTo = (path: string) => { const braced = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/); return braced ? `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/{2,}/g, "/") : path.includes(" => ") ? path.split(" => ")[1]! : path; };
21
+ /** Lines a commit changes, for the size cap: a moved file counts only the edits it carries, not its whole body twice. */
22
+ const stagedLineStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "-M"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: renamedTo(path.join("\t")) }; });
19
23
  const stagedStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "--no-renames"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: path.join("\t") }; });
20
24
 
21
25
  export async function policy(root: string): Promise<HookPolicy> { const file = join(root, "atdd-bun.yaml"); if (!existsSync(file)) return defaultHookPolicy; const data = Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown>; const configuredWorktrees = data?.worktrees && typeof data.worktrees === "object" && !Array.isArray(data.worktrees) ? data.worktrees as Record<string, unknown> : {}; return { ...defaultHookPolicy, ...Object.fromEntries(Object.entries(data ?? {}).filter(([key, value]) => key in defaultHookPolicy && key !== "worktrees" && typeof value === typeof (defaultHookPolicy as any)[key])), protected_branches: strings(data?.protected_branches) ?? defaultHookPolicy.protected_branches, registry_paths: strings(data?.registry_paths) ?? defaultHookPolicy.registry_paths, worktrees: { ...defaultWorktreePolicy, ...Object.fromEntries(Object.entries(configuredWorktrees).filter(([key, value]) => key in defaultWorktreePolicy && typeof value === typeof (defaultWorktreePolicy as any)[key])) } }; }
@@ -31,7 +35,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
31
35
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
32
36
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
33
37
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
34
- if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = [...new Set([...await files(root, ["diff", "--name-only"]), ...await files(root, ["ls-files", "--others", "--exclude-standard"])])], registries = staged.filter(path => isRegistry(cfg, path)), counted = staged.length - registries.length, lines = (await stagedStats(root)).filter(row => !isRegistry(cfg, row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (dirty.length > cfg.max_uncommitted_files) return bad(`unstaged or untracked files ${dirty.length} exceed ${cfg.max_uncommitted_files}; the staged commit itself is not counted`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); return cfg.require_traceability || registries.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
38
+ if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = [...new Set([...await files(root, ["diff", "--name-only"]), ...await files(root, ["ls-files", "--others", "--exclude-standard"])])], registries = staged.filter(path => isRegistry(cfg, path)), counted = staged.length - registries.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (dirty.length > cfg.max_uncommitted_files) return bad(`unstaged or untracked files ${dirty.length} exceed ${cfg.max_uncommitted_files}; the staged commit itself is not counted`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); return cfg.require_traceability || registries.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
35
39
  if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
36
40
  if (event === "pre-push") { for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [,,remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); const local = row[1]; if (local && !/^0+$/.test(local)) { const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, count = Number((await git(root, ["rev-list", "--count", range])).out); if (count > cfg.max_commits_per_push) return bad(`commits per push ${count} exceed ${cfg.max_commits_per_push}`); } } return validation(root, await files(root, ["diff", "--name-only", "HEAD~1..HEAD"]), true); }
37
41
  if (event === "post-commit") { const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }