@hublo/sentinel 1.0.1 → 1.0.3

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.
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ FLAVOURS,
4
+ TARGETS,
5
+ VERBS,
3
6
  availableTargets,
4
7
  describeFramework,
5
8
  dispatch,
@@ -11,7 +14,7 @@ import {
11
14
  registerAdapters,
12
15
  resolve,
13
16
  resolveBin
14
- } from "../chunk-HKGCWPRT.js";
17
+ } from "../chunk-BN3TB6TF.js";
15
18
 
16
19
  // bin/sentinel.ts
17
20
  import { program } from "commander";
@@ -106,20 +109,6 @@ function resolveContext(cwd2, opts2) {
106
109
  );
107
110
  }
108
111
 
109
- // src/core/domain.ts
110
- var VERBS = ["run", "inspect", "init", "migrate", "report", "status"];
111
- var TARGETS = [
112
- "lint",
113
- "format",
114
- "typescript",
115
- "build",
116
- "test",
117
- "static-analysis",
118
- "runtime-analysis",
119
- "arch"
120
- ];
121
- var FLAVOURS = ["react", "nest", "svelte", "node"];
122
-
123
112
  // src/core/orchestrate.ts
124
113
  async function analyse(params) {
125
114
  const results = [];
@@ -141,14 +130,6 @@ async function analyse(params) {
141
130
  );
142
131
  }
143
132
  }
144
- const ctx = {
145
- module: module.name,
146
- cwd: module.root,
147
- flavour: flavour2,
148
- ci: params.ci,
149
- fix: params.fix,
150
- maxDiagnostics: params.maxDiagnostics
151
- };
152
133
  for (const target of params.targets) {
153
134
  let adapter;
154
135
  try {
@@ -167,13 +148,22 @@ async function analyse(params) {
167
148
  }
168
149
  continue;
169
150
  }
151
+ const effectiveFlavour = params.flavour ?? adapter.declaredFlavour?.(module.root) ?? flavour2;
152
+ const ctx = {
153
+ module: module.name,
154
+ cwd: module.root,
155
+ flavour: effectiveFlavour,
156
+ ci: params.ci,
157
+ fix: params.fix,
158
+ maxDiagnostics: params.maxDiagnostics
159
+ };
170
160
  try {
171
161
  if (params.verb === "run") {
172
162
  if (params.modules.length > 1) {
173
163
  const err = palette(process.stderr);
174
164
  process.stderr.write(
175
165
  `
176
- ${err.accent("\u25B6")} ${err.strong(module.name)} ${err.dim(`(${flavour2})`)} ${target}
166
+ ${err.accent("\u25B6")} ${err.strong(module.name)} ${err.dim(`(${effectiveFlavour})`)} ${target}
177
167
  `
178
168
  );
179
169
  }
@@ -181,7 +171,7 @@ async function analyse(params) {
181
171
  results.push({
182
172
  project: module.name,
183
173
  target,
184
- flavour: flavour2,
174
+ flavour: effectiveFlavour,
185
175
  ok: result.ok,
186
176
  status: result.ok ? "ok" : "failed",
187
177
  data: {}
@@ -192,7 +182,7 @@ async function analyse(params) {
192
182
  results.push({
193
183
  project: module.name,
194
184
  target,
195
- flavour: flavour2,
185
+ flavour: effectiveFlavour,
196
186
  ok: result.ok,
197
187
  status: result.ok ? "ok" : "failed",
198
188
  data: result.metrics ?? {}
@@ -203,7 +193,7 @@ async function analyse(params) {
203
193
  results.push({
204
194
  project: module.name,
205
195
  target,
206
- flavour: flavour2,
196
+ flavour: effectiveFlavour,
207
197
  ok: true,
208
198
  status: "ok",
209
199
  data: config
@@ -214,7 +204,7 @@ async function analyse(params) {
214
204
  results.push({
215
205
  project: module.name,
216
206
  target,
217
- flavour: flavour2,
207
+ flavour: effectiveFlavour,
218
208
  ok,
219
209
  status: ok ? "ok" : "failed",
220
210
  data: status
@@ -226,7 +216,7 @@ async function analyse(params) {
226
216
  results.push({
227
217
  project: module.name,
228
218
  target,
229
- flavour: flavour2,
219
+ flavour: effectiveFlavour,
230
220
  ok: false,
231
221
  status: "failed",
232
222
  data: { error: message }
@@ -529,8 +519,13 @@ async function runVerb() {
529
519
  maxDiagnostics,
530
520
  ci: Boolean(opts.ci),
531
521
  fix: Boolean(opts.fix),
532
- onProgress: (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
533
- `))
522
+ // Per-module progress is a HUMAN affordance: it tells someone staring at a slow sweep
523
+ // that it is alive. On this monorepo's 441 projects it is 441 lines that bury the four
524
+ // that matter, and it is pure noise in a captured log. So emit it only for an
525
+ // interactive terminal; a pipe, CI, or a generated trace gets the results and the
526
+ // one-line summary, which is all any of them can use.
527
+ onProgress: process.stderr.isTTY ? (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
528
+ `)) : void 0
534
529
  });
535
530
  const summary = generateSummaries(results, targets);
536
531
  if (opts.json && verb !== "run") {
@@ -613,18 +608,24 @@ sentinel (${type}): ${asMessage(err)}
613
608
  }
614
609
  return worst;
615
610
  }
611
+ async function exitWithoutTruncating(code) {
612
+ process.exitCode = code;
613
+ const stream = process.stdout;
614
+ if (stream.write("")) return;
615
+ await new Promise((resolve2) => stream.once("drain", () => resolve2()));
616
+ }
616
617
  async function main() {
617
618
  if (verb === "migrate") {
618
619
  program.error("--migrate is planned and not available yet; use --init to set a module up.");
619
620
  }
620
621
  const runSelectedVerb = verb === "init" ? runInit : runVerb;
621
622
  const exitCode = await runSelectedVerb();
622
- process.exit(exitCode);
623
+ await exitWithoutTruncating(exitCode);
623
624
  }
624
625
  main().catch((error) => {
625
626
  process.stderr.write(`
626
627
  sentinel: ${asMessage(error)}
627
628
  `);
628
- process.exit(1);
629
+ void exitWithoutTruncating(1);
629
630
  });
630
631
  //# sourceMappingURL=sentinel.js.map
@@ -62,6 +62,20 @@ var BaseAdapter = class {
62
62
  }
63
63
  };
64
64
 
65
+ // src/core/domain.ts
66
+ var VERBS = ["run", "inspect", "init", "migrate", "report", "status"];
67
+ var TARGETS = [
68
+ "lint",
69
+ "format",
70
+ "typescript",
71
+ "build",
72
+ "test",
73
+ "static-analysis",
74
+ "runtime-analysis",
75
+ "arch"
76
+ ];
77
+ var FLAVOURS = ["react", "nest", "svelte", "node"];
78
+
65
79
  // src/shared/color.ts
66
80
  import { styleText } from "util";
67
81
  function palette(stream) {
@@ -125,8 +139,12 @@ function readNxProjectName(dir) {
125
139
 
126
140
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
127
141
  import { spawnSync } from "child_process";
128
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
129
- import { basename, join as join5 } from "path";
142
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
143
+ import { basename, join as join6 } from "path";
144
+
145
+ // src/core/config/flavour-evidence.ts
146
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "fs";
147
+ import { join as join2 } from "path";
130
148
 
131
149
  // src/shared/jsonc.ts
132
150
  import { parse, printParseErrorCode } from "jsonc-parser";
@@ -140,14 +158,92 @@ function parseJsonc(text, source = "config") {
140
158
  return value;
141
159
  }
142
160
 
161
+ // src/core/config/flavour-evidence.ts
162
+ var PATH_SIGNALS = [
163
+ {
164
+ flavour: "nest",
165
+ pattern: /(^|\/)apps\/nest\//,
166
+ evidence: "it lives under apps/nest"
167
+ }
168
+ ];
169
+ var DEPENDENCY_SIGNALS = [
170
+ { flavour: "react", pattern: /^(react|react-dom|@types\/react)$|^eslint-plugin-react/ },
171
+ { flavour: "svelte", pattern: /^svelte$|^@sveltejs\// },
172
+ { flavour: "nest", pattern: /^@nestjs\// }
173
+ ];
174
+ function dependencyNames(cwd) {
175
+ const path = join2(cwd, "package.json");
176
+ if (!existsSync2(path)) return [];
177
+ try {
178
+ const manifest = parseJsonc(
179
+ readFileSync2(path, "utf8"),
180
+ path
181
+ );
182
+ return [
183
+ ...Object.keys(manifest.dependencies ?? {}),
184
+ ...Object.keys(manifest.devDependencies ?? {})
185
+ ];
186
+ } catch {
187
+ return [];
188
+ }
189
+ }
190
+ function declaresJsx(cwd) {
191
+ let entries;
192
+ try {
193
+ entries = readdirSync(cwd).filter(
194
+ (name) => name.startsWith("tsconfig") && name.endsWith(".json")
195
+ );
196
+ } catch {
197
+ return false;
198
+ }
199
+ for (const name of entries) {
200
+ try {
201
+ const config = parseJsonc(
202
+ readFileSync2(join2(cwd, name), "utf8"),
203
+ name
204
+ );
205
+ if (config.compilerOptions?.jsx !== void 0) return true;
206
+ } catch {
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+ function flavourSignals(cwd) {
212
+ const signals = [];
213
+ const dependencies = dependencyNames(cwd);
214
+ for (const { flavour, pattern } of DEPENDENCY_SIGNALS) {
215
+ const hit = dependencies.find((name) => pattern.test(name));
216
+ if (hit) signals.push({ flavour, evidence: `it depends on "${hit}"` });
217
+ }
218
+ if (declaresJsx(cwd) && !signals.some((signal) => signal.flavour === "react")) {
219
+ signals.push({ flavour: "react", evidence: 'its tsconfig sets "jsx"' });
220
+ }
221
+ const location = cwd.replaceAll("\\", "/");
222
+ for (const { flavour, pattern, evidence } of PATH_SIGNALS) {
223
+ if (pattern.test(location) && !signals.some((signal) => signal.flavour === flavour)) {
224
+ signals.push({ flavour, evidence });
225
+ }
226
+ }
227
+ return signals;
228
+ }
229
+ function flavourContradiction(declared, cwd) {
230
+ const signals = flavourSignals(cwd);
231
+ if (signals.length === 0) return void 0;
232
+ if (signals.some((signal) => signal.flavour === declared)) return void 0;
233
+ const [first] = signals;
234
+ if (!first) return void 0;
235
+ const evidence = signals.map((signal) => signal.evidence).join(", and ");
236
+ return `--flavour ${declared} does not match this module: ${evidence}, which makes it "${first.flavour}". Nothing was written. Re-run with --flavour ${first.flavour}, or adopt from the module you actually meant.`;
237
+ }
238
+
143
239
  // src/shared/resolve-bin.ts
144
- import { existsSync as existsSync2 } from "fs";
145
- import { dirname as dirname2, join as join2 } from "path";
240
+ import { existsSync as existsSync3 } from "fs";
241
+ import { dirname as dirname2, join as join3 } from "path";
146
242
  function resolveBin(fromDir, name) {
147
243
  let dir = fromDir;
148
244
  for (; ; ) {
149
- const candidate = join2(dir, "node_modules", ".bin", name);
150
- if (existsSync2(candidate)) return candidate;
245
+ const candidate = join3(dir, "node_modules", ".bin", name);
246
+ if (existsSync3(candidate)) return candidate;
151
247
  const parent = dirname2(dir);
152
248
  if (parent === dir) return void 0;
153
249
  dir = parent;
@@ -184,18 +280,18 @@ function hasShippedPreset(flavour) {
184
280
  }
185
281
 
186
282
  // src/roles/typescript/read-adoption.ts
187
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
188
- import { join as join4 } from "path";
283
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
284
+ import { join as join5 } from "path";
189
285
 
190
286
  // src/roles/typescript/resolve-tsconfig-target.ts
191
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
192
- import { join as join3 } from "path";
287
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
288
+ import { join as join4 } from "path";
193
289
  var TARGET_EXTENDS_MARKERS = ["tsconfig.base.json", "@hublo/sentinel/tsconfig/"];
194
290
  var CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
195
291
  function readExtends(absolutePath) {
196
292
  let parsed;
197
293
  try {
198
- parsed = parseJsonc(readFileSync2(absolutePath, "utf8"), absolutePath);
294
+ parsed = parseJsonc(readFileSync3(absolutePath, "utf8"), absolutePath);
199
295
  } catch {
200
296
  return [];
201
297
  }
@@ -207,11 +303,15 @@ function readExtends(absolutePath) {
207
303
  }
208
304
  function resolveTsconfigTarget(moduleDir) {
209
305
  let existing;
306
+ let existingExtendsSomething = false;
210
307
  for (const candidate of CANDIDATES) {
211
- const absolutePath = join3(moduleDir, candidate);
212
- if (!existsSync3(absolutePath)) continue;
213
- existing ??= candidate;
308
+ const absolutePath = join4(moduleDir, candidate);
309
+ if (!existsSync4(absolutePath)) continue;
214
310
  const extendsValues = readExtends(absolutePath);
311
+ if (existing === void 0) {
312
+ existing = candidate;
313
+ existingExtendsSomething = extendsValues.length > 0;
314
+ }
215
315
  const extendsBase = extendsValues.some(
216
316
  (value) => TARGET_EXTENDS_MARKERS.some((marker) => value.includes(marker))
217
317
  );
@@ -219,7 +319,9 @@ function resolveTsconfigTarget(moduleDir) {
219
319
  return { path: candidate, reason: "extends-base" };
220
320
  }
221
321
  }
222
- if (existing) return { path: existing, reason: "other-chain" };
322
+ if (existing) {
323
+ return { path: existing, reason: existingExtendsSomething ? "other-chain" : "no-extends" };
324
+ }
223
325
  return { path: "tsconfig.json", reason: "none" };
224
326
  }
225
327
 
@@ -240,12 +342,12 @@ var NOT_ADOPTED = (configFile) => ({
240
342
  });
241
343
  function readTsconfigAdoption(cwd) {
242
344
  const target = resolveTsconfigTarget(cwd);
243
- if (target.reason === "none" || !existsSync4(join4(cwd, target.path))) {
345
+ if (target.reason === "none" || !existsSync5(join5(cwd, target.path))) {
244
346
  return NOT_ADOPTED(target.reason === "none" ? null : target.path);
245
347
  }
246
348
  let parsed;
247
349
  try {
248
- parsed = parseJsonc(readFileSync3(join4(cwd, target.path), "utf8"), target.path);
350
+ parsed = parseJsonc(readFileSync4(join5(cwd, target.path), "utf8"), target.path);
249
351
  } catch {
250
352
  return NOT_ADOPTED(target.path);
251
353
  }
@@ -294,6 +396,20 @@ var TscAdapter = class extends BaseAdapter {
294
396
  appliesTo(_flavour) {
295
397
  return true;
296
398
  }
399
+ /**
400
+ * The flavour read from the committed `extends` chain
401
+ * (`@hublo/sentinel/tsconfig/nest` -> `nest`), or undefined when the module has not
402
+ * adopted a preset, so the engine falls back to dependency detection.
403
+ *
404
+ * This is the same detection-free read `--status` uses, and it is why an adopted React app
405
+ * reports `react` even in a monorepo that hoists `react` to the root.
406
+ */
407
+ declaredFlavour(cwd) {
408
+ const { preset } = readTsconfigAdoption(cwd);
409
+ if (!preset) return void 0;
410
+ const name = preset.slice(preset.lastIndexOf("/") + 1);
411
+ return FLAVOURS.includes(name) ? name : void 0;
412
+ }
297
413
  /**
298
414
  * Plan `--init`: make the module extend the sentinel preset with a THIN,
299
415
  * conformant stub, route type-checking through the CLI, and pin the
@@ -314,6 +430,17 @@ var TscAdapter = class extends BaseAdapter {
314
430
  blocked: `no TypeScript preset for flavour "${context.flavour}" yet (shipped: ${SHIPPED_FLAVOURS.join(", ")}). Nothing was written; this module cannot adopt the TypeScript preset until that flavour ships.`
315
431
  };
316
432
  }
433
+ const current = this.declaredFlavour(context.cwd);
434
+ if (current !== void 0 && current !== context.flavour) {
435
+ return {
436
+ operations: [],
437
+ blocked: `this module is already adopted as "${current}", and --flavour says "${context.flavour}". Nothing was written. If the change is deliberate, remove the sentinel preset from the tsconfig \`extends\` chain and re-run; otherwise re-run with --flavour ${current}.`
438
+ };
439
+ }
440
+ const contradiction = flavourContradiction(context.flavour, context.cwd);
441
+ if (contradiction !== void 0) {
442
+ return { operations: [], blocked: contradiction };
443
+ }
317
444
  const target = resolveTsconfigTarget(context.cwd);
318
445
  const preset = `@hublo/sentinel/tsconfig/${context.flavour}`;
319
446
  const addScript = this.packageJsonOperation(context.cwd);
@@ -322,6 +449,12 @@ var TscAdapter = class extends BaseAdapter {
322
449
  operations: [],
323
450
  blocked: `${target.path} extends a config chain sentinel does not handle, so nothing was written. Point it at the workspace base (or a plain config) and re-run.`
324
451
  };
452
+ if (target.reason === "no-extends") {
453
+ return {
454
+ operations: [],
455
+ blocked: `${target.path} extends nothing, so it defines its own complete set of compilerOptions and sentinel will not silently take ownership of them. Nothing was written. Make it extend the workspace base and re-run, and --init will then move the shared options into the preset.`
456
+ };
457
+ }
325
458
  }
326
459
  if (target.reason === "none") {
327
460
  const contents = JSON.stringify({ extends: preset, include: ["src"] }, null, 2) + "\n";
@@ -331,7 +464,7 @@ var TscAdapter = class extends BaseAdapter {
331
464
  };
332
465
  }
333
466
  const existing = parseJsonc(
334
- readFileSync4(join5(context.cwd, target.path), "utf8"),
467
+ readFileSync5(join6(context.cwd, target.path), "utf8"),
335
468
  target.path
336
469
  );
337
470
  const extendsChain = composeExtends(existing.extends, preset);
@@ -366,7 +499,7 @@ var TscAdapter = class extends BaseAdapter {
366
499
  packageJsonOperation(cwd) {
367
500
  const own = readOwnPackage();
368
501
  const devDependencies = { [own.name]: own.version };
369
- if (existsSync5(join5(cwd, "package.json"))) {
502
+ if (existsSync6(join6(cwd, "package.json"))) {
370
503
  return {
371
504
  kind: "merge-json",
372
505
  path: "package.json",
@@ -414,7 +547,7 @@ var TscAdapter = class extends BaseAdapter {
414
547
  * check.
415
548
  */
416
549
  typecheckTarget(cwd) {
417
- if (existsSync5(join5(cwd, "tsconfig.json"))) return "tsconfig.json";
550
+ if (existsSync6(join6(cwd, "tsconfig.json"))) return "tsconfig.json";
418
551
  const target = resolveTsconfigTarget(cwd);
419
552
  return target.reason === "none" ? null : target.path;
420
553
  }
@@ -598,7 +731,7 @@ function diffLines(before, after) {
598
731
  }
599
732
 
600
733
  // src/core/apply-plan.ts
601
- import { existsSync as existsSync6, readFileSync as readFileSync5, renameSync, writeFileSync } from "fs";
734
+ import { existsSync as existsSync7, readFileSync as readFileSync6, renameSync, writeFileSync } from "fs";
602
735
  import { resolve as resolve2, sep } from "path";
603
736
  import { applyEdits, modify } from "jsonc-parser";
604
737
 
@@ -617,7 +750,7 @@ function resolveWithinRoot(cwd, relativePath) {
617
750
  return absolutePath;
618
751
  }
619
752
  function readIfExists(absolutePath) {
620
- return existsSync6(absolutePath) ? readFileSync5(absolutePath, "utf8") : void 0;
753
+ return existsSync7(absolutePath) ? readFileSync6(absolutePath, "utf8") : void 0;
621
754
  }
622
755
  function* leaves(value, prefix = []) {
623
756
  for (const [key, keyValue] of Object.entries(value)) {
@@ -745,8 +878,9 @@ async function dispatch(opts) {
745
878
  if (opts.verb !== "init") {
746
879
  throw new Error(`dispatch handles --init only; --${opts.verb} routes through analyse()`);
747
880
  }
748
- const flavour = resolveFlavour(opts);
749
- const adapter = resolve(opts.target, flavour, opts.runner);
881
+ const detected = resolveFlavour(opts);
882
+ const adapter = resolve(opts.target, detected, opts.runner);
883
+ const flavour = opts.flavour ?? adapter.declaredFlavour?.(opts.cwd) ?? detected;
750
884
  const context = { cwd: opts.cwd, flavour };
751
885
  const plan = await adapter.plan(context);
752
886
  if (plan.blocked) {
@@ -772,6 +906,9 @@ export {
772
906
  availableTargets,
773
907
  resolve,
774
908
  BaseAdapter,
909
+ VERBS,
910
+ TARGETS,
911
+ FLAVOURS,
775
912
  palette,
776
913
  readOwnPackage,
777
914
  readOwnVersion,
@@ -783,4 +920,4 @@ export {
783
920
  detectFramework,
784
921
  dispatch
785
922
  };
786
- //# sourceMappingURL=chunk-HKGCWPRT.js.map
923
+ //# sourceMappingURL=chunk-BN3TB6TF.js.map
package/dist/index.d.ts CHANGED
@@ -169,6 +169,17 @@ interface Adapter {
169
169
  * adapters can share a (target, runner) if they specialise different flavours.
170
170
  */
171
171
  appliesTo(flavour: Flavour): boolean;
172
+ /**
173
+ * The flavour this module has COMMITTED to, read from its own config, or undefined when
174
+ * it has not adopted a preset yet. Optional: an adapter implements it only when its tool's
175
+ * config states the flavour unambiguously.
176
+ *
177
+ * The engine prefers this over dependency detection, because in a monorepo dependencies are
178
+ * hoisted to the root: `host-admin` declares no `react`, so detection honestly answers
179
+ * `node` while the committed `extends` says `.../tsconfig/react`. Reporting the detected
180
+ * value there would show every adopted module as `node`, which is simply untrue.
181
+ */
182
+ declaredFlavour?(cwd: string): Flavour | undefined;
172
183
  /**
173
184
  * Plan what `--init` should write, as declarative operations (see
174
185
  * `FileOperation`). Given a normalized `UpdateContext`, the adapter may READ the
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  registerAdapters,
8
8
  resolve,
9
9
  setDefaultRunner
10
- } from "./chunk-HKGCWPRT.js";
10
+ } from "./chunk-BN3TB6TF.js";
11
11
  export {
12
12
  BaseAdapter,
13
13
  all,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",