@hublo/sentinel 1.6.0-alpha.0 → 1.6.0-alpha.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
@@ -141,7 +141,7 @@ The per-tool knowledge (eslint → `eslint.config.js`, tsc → `tsconfig`, …)
141
141
 
142
142
  **Registry: public npm**, under `@hublo`. We started on **GitHub Packages (private)** and moved off it: it authenticates every consumer, including a one-off run, which is incompatible with the zero-setup adoption above. Public npm needs no consumer auth, so a module adopts sentinel without any `.npmrc` or token. Releases go out through the repo's `publish` workflow, never from a laptop; a prerelease is published under its prerelease dist-tag (`alpha`) and a stable one under `latest`.
143
143
 
144
- **Node.** sentinel needs **Node >= 24.15**, which is the version the monorepo it serves pins in its own `.node-version`. The floor follows the consumer deliberately: a lower one means testing a runtime nobody runs, and it twice let an API through that the types allowed and the runtime did not. It fails fast with a clear message on an older runtime rather than crashing. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via `fnm`/`nvm`; you do not need to change the project's own Node.
144
+ **Node.** sentinel needs **Node >= 24.15**, which is the version the monorepo it serves pins in its own `.node-version`. The floor follows the consumer deliberately: a lower one means testing a runtime nobody runs, and it twice let an API through that the types allowed and the runtime did not. It fails fast with a clear message on an older runtime rather than crashing. That message comes from sentinel itself, so `npx` must run on Node >= 24.15 too: the npm 10 that Node 20 and 22 bundle fails while installing sentinel (`Cannot read properties of null (reading 'edgesOut')`), before sentinel's own check can run. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via `fnm`/`nvm`; you do not need to change the project's own Node.
145
145
 
146
146
  **Try it without installing.** A one-off run needs no auth and touches nothing:
147
147
 
@@ -12,7 +12,7 @@ import {
12
12
  presetNotInstalledYet,
13
13
  registerAdapters,
14
14
  resolve
15
- } from "../chunk-PC5R4DX5.js";
15
+ } from "../chunk-5267A6X3.js";
16
16
  import {
17
17
  resolveContext
18
18
  } from "../chunk-P7NYSMLS.js";
@@ -34,7 +34,7 @@ import {
34
34
  TARGETS,
35
35
  VERBS
36
36
  } from "../chunk-7E6YTTAK.js";
37
- import "../chunk-NAFI7OLI.js";
37
+ import "../chunk-4EIY7TTR.js";
38
38
  import "../chunk-O7REVMOC.js";
39
39
  import "../chunk-QXFCZON7.js";
40
40
 
@@ -183,6 +183,11 @@ async function analyse(params) {
183
183
  }
184
184
  return { results, worstCode };
185
185
  }
186
+ function definedOnly(source, keys) {
187
+ return Object.fromEntries(
188
+ keys.flatMap((key) => source[key] === void 0 ? [] : [[key, source[key]]])
189
+ );
190
+ }
186
191
  async function analyseRepository(params) {
187
192
  const results = [];
188
193
  let worstCode = 0;
@@ -209,8 +214,10 @@ async function analyseRepository(params) {
209
214
  ok: result.ok,
210
215
  status: result.ok ? "ok" : "failed",
211
216
  // A run row carries the verdict's two inputs and nothing more: the report itself was just
212
- // printed above it, and repeating it inline would print it twice.
213
- data: params.verb === "run" ? { level: metrics.level, blocking: metrics.blocking } : metrics
217
+ // printed above it, and repeating it inline would print it twice. A run that could not
218
+ // start has no blocking count, and carries its `error` instead: a key is left out when it
219
+ // has no value, never printed as `undefined`.
220
+ data: params.verb === "run" ? definedOnly(metrics, ["level", "blocking", "error"]) : metrics
214
221
  });
215
222
  worstCode = Math.max(worstCode, result.code);
216
223
  } catch (error) {
@@ -22,10 +22,23 @@ var NESTED_KEYS = {
22
22
  function isRecord(value) {
23
23
  return typeof value === "object" && value !== null && !Array.isArray(value);
24
24
  }
25
+ function kindOf(value) {
26
+ if (value === null) return "null";
27
+ if (Array.isArray(value)) return "an array";
28
+ if (value instanceof RegExp) return "a RegExp";
29
+ if (value === "") return "an empty string";
30
+ const type = typeof value;
31
+ return type === "object" ? "an object" : `a ${type}`;
32
+ }
33
+ function badEntry(path, list, valid) {
34
+ const index = list.findIndex((entry) => !valid(entry));
35
+ return index === -1 ? void 0 : `${path}[${index}] is ${kindOf(list[index])}`;
36
+ }
25
37
  function stringListProblems(path, value) {
26
38
  if (value === void 0) return [];
27
- if (!Array.isArray(value)) return [`${path} must be an array of strings`];
28
- return value.some((entry) => typeof entry !== "string" || entry === "") ? [`${path} must hold non-empty strings only`] : [];
39
+ if (!Array.isArray(value)) return [`${path} must be an array of strings, got ${kindOf(value)}`];
40
+ const bad = badEntry(path, value, (entry) => typeof entry === "string" && entry !== "");
41
+ return bad ? [`${path} must hold non-empty strings only: ${bad}`] : [];
29
42
  }
30
43
  function problemsOf(input) {
31
44
  const problems = [];
@@ -37,10 +50,11 @@ function problemsOf(input) {
37
50
  const nested = NESTED_KEYS[key];
38
51
  if (nested === void 0) {
39
52
  if (key === "knownExternalPatterns") {
53
+ const bad = Array.isArray(value) ? badEntry(key, value, (entry) => entry instanceof RegExp || typeof entry === "string") : void 0;
40
54
  if (value !== void 0 && !Array.isArray(value)) {
41
- problems.push(`${key} must be an array of RegExp or strings`);
42
- } else if (Array.isArray(value) && value.some((entry) => !(entry instanceof RegExp) && typeof entry !== "string")) {
43
- problems.push(`${key} must hold RegExp or strings only`);
55
+ problems.push(`${key} must be an array of RegExp or strings, got ${kindOf(value)}`);
56
+ } else if (bad) {
57
+ problems.push(`${key} must hold RegExp or strings only: ${bad}`);
44
58
  }
45
59
  } else {
46
60
  problems.push(...stringListProblems(key, value));
@@ -49,7 +63,7 @@ function problemsOf(input) {
49
63
  }
50
64
  if (value === void 0) continue;
51
65
  if (!isRecord(value)) {
52
- problems.push(`${key} must be an object`);
66
+ problems.push(`${key} must be an object, got ${kindOf(value)}`);
53
67
  continue;
54
68
  }
55
69
  for (const [inner, innerValue] of Object.entries(value)) {
@@ -60,7 +74,7 @@ function problemsOf(input) {
60
74
  return problems;
61
75
  }
62
76
  function defineEnv(input = {}) {
63
- if (!isRecord(input)) throw new Error("env config must export an object");
77
+ if (!isRecord(input)) throw new Error(`env config must export an object, got ${kindOf(input)}`);
64
78
  const problems = problemsOf(input);
65
79
  if (problems.length > 0) {
66
80
  throw new Error(`invalid env config:
@@ -72,4 +86,4 @@ function defineEnv(input = {}) {
72
86
  export {
73
87
  defineEnv
74
88
  };
75
- //# sourceMappingURL=chunk-NAFI7OLI.js.map
89
+ //# sourceMappingURL=chunk-4EIY7TTR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/roles/env/configs/define.ts"],"sourcesContent":["/**\n * `defineEnv`: what a repository writes to declare its own env facts, all of them optional.\n *\n * ```js\n * // env.config.mjs, at the repository root\n * import { defineEnv } from '@hublo/sentinel/env'\n *\n * export default defineEnv({\n * externallyProvided: ['NODE_ENV'],\n * allowNonBlankedSecrets: ['apps/front/x/.env.example:LEGACY_API_KEY'],\n * })\n * ```\n *\n * A repository that does not install sentinel, and runs it with `npx`, cannot import this module:\n * its own `node_modules` has no `@hublo/sentinel`. So the file may export the same object PLAIN,\n * and the role passes it through this function itself. Both spellings mean the same thing, and the\n * function exists for the one that can import it: it validates at the moment the file is written,\n * in an editor that knows the types.\n *\n * ## What a repository declares, and what stays sentinel's\n *\n * A repository declares FACTS about itself: which variables its CI injects, which committed value\n * was reviewed and is not a secret, which Dockerfile must run as root, where its code lives. It\n * does not redefine what a secret NAME looks like or how code reads env, which are the same in\n * every repository and ship in `presets/shared.json`.\n *\n * Two semantics, stated once:\n *\n * - `readers`, `envFiles` and `contracts` ADD to what sentinel ships, so no repository loses\n * `getOrFail` or `**\\/.env` by declaring a reader or a glob of its own;\n * - `scan` and `references` REPLACE, key by key: a repository naming its `roots` says where its\n * code is, and keeping `.` beside them would scan everything anyway.\n *\n * ⛔ An unknown key is refused, by name. The file is `.mjs`, so nothing type-checks it, and a key\n * spelled `allowNonBlankedSecret` would otherwise be ignored in silence while the finding it was\n * meant to downgrade kept failing the build.\n */\n\n/** A source scope a repository may narrow, every key optional. */\nexport interface EnvScopeInput {\n roots?: string[]\n ext?: string[]\n excludeSuffixes?: string[]\n excludeDirs?: string[]\n}\n\n/** What `env.config.mjs` exports. */\nexport interface EnvInput {\n /** Required variables in no contract because CI injects them or a secret store provides them. */\n externallyProvided?: string[]\n /** Declared variables consumed outside scannable source, so never reported dead (rule 4). */\n knownExternalVars?: string[]\n /** The same, by pattern, for a whole category such as `/_DATABASE_URL$/`. */\n knownExternalPatterns?: (RegExp | string)[]\n /** `<file>:<VAR>` locations whose committed value was reviewed and is not a secret (rule 6). */\n allowNonBlankedSecrets?: string[]\n /** Committed real env files permitted anyway, such as a test fixture (rule 2). */\n allowedCommittedEnvFiles?: string[]\n /** Globs of env files to read, ADDED to `**\\/.env`, `**\\/.env.*` and `**\\/*.env`. */\n envFiles?: string[]\n /** Globs of contracts, ADDED to `**\\/*.env*.example`. */\n contracts?: string[]\n /** Where the named readers are looked for (rules 1 and 3). Replaces, key by key. */\n scan?: EnvScopeInput\n /** Where direct `process.env` reads are looked for (rules 3 and 4). Replaces, key by key. */\n references?: EnvScopeInput\n /** Reader names ADDED to sentinel's: `required` throw when absent, `optional` default. */\n readers?: { required?: string[]; optional?: string[] }\n /** Docker rules: Dockerfiles allowed to run as root, and path prefixes not checked at all. */\n docker?: { allowRoot?: string[]; excludePaths?: string[] }\n}\n\nconst TOP_LEVEL_KEYS = new Set<string>([\n 'externallyProvided',\n 'knownExternalVars',\n 'knownExternalPatterns',\n 'allowNonBlankedSecrets',\n 'allowedCommittedEnvFiles',\n 'envFiles',\n 'contracts',\n 'scan',\n 'references',\n 'readers',\n 'docker',\n])\nconst SCOPE_KEYS = new Set(['roots', 'ext', 'excludeSuffixes', 'excludeDirs'])\nconst NESTED_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {\n scan: SCOPE_KEYS,\n references: SCOPE_KEYS,\n readers: new Set(['required', 'optional']),\n docker: new Set(['allowRoot', 'excludePaths']),\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * What a value IS, in a word, for a message that says what was found instead of what was wanted.\n * Its kind, never its content: a config line can hold a secret's location, and a refusal has no\n * reason to repeat it.\n */\nfunction kindOf(value: unknown): string {\n if (value === null) return 'null'\n if (Array.isArray(value)) return 'an array'\n if (value instanceof RegExp) return 'a RegExp'\n if (value === '') return 'an empty string'\n const type = typeof value\n return type === 'object' ? 'an object' : `a ${type}`\n}\n\n/** The first entry of a list that fails `valid`, named by its index and its kind. */\nfunction badEntry(path: string, list: readonly unknown[], valid: (entry: unknown) => boolean) {\n const index = list.findIndex((entry) => !valid(entry))\n return index === -1 ? undefined : `${path}[${index}] is ${kindOf(list[index])}`\n}\n\n/** Every problem with one list: it must be an array of non-empty strings. */\nfunction stringListProblems(path: string, value: unknown): string[] {\n if (value === undefined) return []\n if (!Array.isArray(value)) return [`${path} must be an array of strings, got ${kindOf(value)}`]\n const bad = badEntry(path, value, (entry) => typeof entry === 'string' && entry !== '')\n return bad ? [`${path} must hold non-empty strings only: ${bad}`] : []\n}\n\n/** Every problem with the input, each naming the key, so one run lists them all. */\nfunction problemsOf(input: Record<string, unknown>): string[] {\n const problems: string[] = []\n for (const [key, value] of Object.entries(input)) {\n if (!TOP_LEVEL_KEYS.has(key)) {\n problems.push(`unknown key \"${key}\"`)\n continue\n }\n const nested = NESTED_KEYS[key]\n if (nested === undefined) {\n if (key === 'knownExternalPatterns') {\n const bad = Array.isArray(value)\n ? badEntry(key, value, (entry) => entry instanceof RegExp || typeof entry === 'string')\n : undefined\n if (value !== undefined && !Array.isArray(value)) {\n problems.push(`${key} must be an array of RegExp or strings, got ${kindOf(value)}`)\n } else if (bad) {\n problems.push(`${key} must hold RegExp or strings only: ${bad}`)\n }\n } else {\n problems.push(...stringListProblems(key, value))\n }\n continue\n }\n if (value === undefined) continue\n if (!isRecord(value)) {\n problems.push(`${key} must be an object, got ${kindOf(value)}`)\n continue\n }\n for (const [inner, innerValue] of Object.entries(value)) {\n if (!nested.has(inner)) problems.push(`unknown key \"${key}.${inner}\"`)\n else problems.push(...stringListProblems(`${key}.${inner}`, innerValue))\n }\n }\n return problems\n}\n\n/**\n * Validate a repository's env declaration and return it unchanged.\n *\n * The defaults are NOT merged in here. They ship with sentinel and are applied when the role runs,\n * so changing one reaches every repository at its next sentinel version instead of leaving a copy\n * frozen in each config. That also makes this function idempotent, which is what lets the role\n * pass a plain exported object through it a second time.\n *\n * @param input the declaration; omitted, the repository declares nothing\n * @returns the same declaration\n * @throws naming every unknown key and every malformed value at once\n */\nexport function defineEnv(input: EnvInput = {}): EnvInput {\n if (!isRecord(input)) throw new Error(`env config must export an object, got ${kindOf(input)}`)\n const problems = problemsOf(input)\n if (problems.length > 0) {\n throw new Error(`invalid env config:\\n - ${problems.join('\\n - ')}`)\n }\n return input\n}\n"],"mappings":";AAwEA,IAAM,iBAAiB,oBAAI,IAAY;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AACD,IAAM,aAAa,oBAAI,IAAI,CAAC,SAAS,OAAO,mBAAmB,aAAa,CAAC;AAC7E,IAAM,cAA6D;AAAA,EACjE,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,SAAS,oBAAI,IAAI,CAAC,YAAY,UAAU,CAAC;AAAA,EACzC,QAAQ,oBAAI,IAAI,CAAC,aAAa,cAAc,CAAC;AAC/C;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAOA,SAAS,OAAO,OAAwB;AACtC,MAAI,UAAU,KAAM,QAAO;AAC3B,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,MAAI,iBAAiB,OAAQ,QAAO;AACpC,MAAI,UAAU,GAAI,QAAO;AACzB,QAAM,OAAO,OAAO;AACpB,SAAO,SAAS,WAAW,cAAc,KAAK,IAAI;AACpD;AAGA,SAAS,SAAS,MAAc,MAA0B,OAAoC;AAC5F,QAAM,QAAQ,KAAK,UAAU,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC;AACrD,SAAO,UAAU,KAAK,SAAY,GAAG,IAAI,IAAI,KAAK,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC;AAC/E;AAGA,SAAS,mBAAmB,MAAc,OAA0B;AAClE,MAAI,UAAU,OAAW,QAAO,CAAC;AACjC,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC,GAAG,IAAI,qCAAqC,OAAO,KAAK,CAAC,EAAE;AAC9F,QAAM,MAAM,SAAS,MAAM,OAAO,CAAC,UAAU,OAAO,UAAU,YAAY,UAAU,EAAE;AACtF,SAAO,MAAM,CAAC,GAAG,IAAI,sCAAsC,GAAG,EAAE,IAAI,CAAC;AACvE;AAGA,SAAS,WAAW,OAA0C;AAC5D,QAAM,WAAqB,CAAC;AAC5B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,CAAC,eAAe,IAAI,GAAG,GAAG;AAC5B,eAAS,KAAK,gBAAgB,GAAG,GAAG;AACpC;AAAA,IACF;AACA,UAAM,SAAS,YAAY,GAAG;AAC9B,QAAI,WAAW,QAAW;AACxB,UAAI,QAAQ,yBAAyB;AACnC,cAAM,MAAM,MAAM,QAAQ,KAAK,IAC3B,SAAS,KAAK,OAAO,CAAC,UAAU,iBAAiB,UAAU,OAAO,UAAU,QAAQ,IACpF;AACJ,YAAI,UAAU,UAAa,CAAC,MAAM,QAAQ,KAAK,GAAG;AAChD,mBAAS,KAAK,GAAG,GAAG,+CAA+C,OAAO,KAAK,CAAC,EAAE;AAAA,QACpF,WAAW,KAAK;AACd,mBAAS,KAAK,GAAG,GAAG,sCAAsC,GAAG,EAAE;AAAA,QACjE;AAAA,MACF,OAAO;AACL,iBAAS,KAAK,GAAG,mBAAmB,KAAK,KAAK,CAAC;AAAA,MACjD;AACA;AAAA,IACF;AACA,QAAI,UAAU,OAAW;AACzB,QAAI,CAAC,SAAS,KAAK,GAAG;AACpB,eAAS,KAAK,GAAG,GAAG,2BAA2B,OAAO,KAAK,CAAC,EAAE;AAC9D;AAAA,IACF;AACA,eAAW,CAAC,OAAO,UAAU,KAAK,OAAO,QAAQ,KAAK,GAAG;AACvD,UAAI,CAAC,OAAO,IAAI,KAAK,EAAG,UAAS,KAAK,gBAAgB,GAAG,IAAI,KAAK,GAAG;AAAA,UAChE,UAAS,KAAK,GAAG,mBAAmB,GAAG,GAAG,IAAI,KAAK,IAAI,UAAU,CAAC;AAAA,IACzE;AAAA,EACF;AACA,SAAO;AACT;AAcO,SAAS,UAAU,QAAkB,CAAC,GAAa;AACxD,MAAI,CAAC,SAAS,KAAK,EAAG,OAAM,IAAI,MAAM,yCAAyC,OAAO,KAAK,CAAC,EAAE;AAC9F,QAAM,WAAW,WAAW,KAAK;AACjC,MAAI,SAAS,SAAS,GAAG;AACvB,UAAM,IAAI,MAAM;AAAA,MAA4B,SAAS,KAAK,QAAQ,CAAC,EAAE;AAAA,EACvE;AACA,SAAO;AACT;","names":[]}
@@ -84,7 +84,7 @@ import {
84
84
  } from "./chunk-7E6YTTAK.js";
85
85
  import {
86
86
  defineEnv
87
- } from "./chunk-NAFI7OLI.js";
87
+ } from "./chunk-4EIY7TTR.js";
88
88
  import {
89
89
  globToRegExp
90
90
  } from "./chunk-O7REVMOC.js";
@@ -192,7 +192,15 @@ var shared_default = {
192
192
  ".spec.js",
193
193
  ".test.js",
194
194
  ".spec.jsx",
195
- ".test.jsx"
195
+ ".test.jsx",
196
+ ".spec.mts",
197
+ ".test.mts",
198
+ ".spec.cts",
199
+ ".test.cts",
200
+ ".spec.mjs",
201
+ ".test.mjs",
202
+ ".spec.cjs",
203
+ ".test.cjs"
196
204
  ],
197
205
  excludeDirs: ["node_modules", "dist"]
198
206
  },
@@ -1400,16 +1408,25 @@ function deriveVars(files, readers) {
1400
1408
  return { required, optional };
1401
1409
  }
1402
1410
  var ENV_LINE = /^(?:export\s+)?([\w.-]+)\s*=(.*)$/;
1403
- function isBlankValue(value) {
1404
- const trimmed = value.trim();
1405
- const quoted = /^(['"])(.*)\1$/.exec(trimmed);
1406
- return (quoted ? quoted[2] : trimmed) === "";
1407
- }
1408
- function openQuote(value) {
1409
- const trimmed = value.trim();
1410
- const quote = trimmed[0];
1411
- if (quote !== '"' && quote !== "'" && quote !== "`") return void 0;
1412
- return trimmed.length > 1 && trimmed.endsWith(quote) ? void 0 : quote;
1411
+ var QUOTES = /* @__PURE__ */ new Set(['"', "'", "`"]);
1412
+ function closingQuote(text, quote, from) {
1413
+ for (let index = from; index < text.length; index += 1) {
1414
+ if (text[index] === "\\" && text[index + 1] === quote) index += 1;
1415
+ else if (text[index] === quote) return index;
1416
+ }
1417
+ return -1;
1418
+ }
1419
+ function readValue(raw) {
1420
+ const value = raw.trimStart();
1421
+ const quote = value[0];
1422
+ if (quote !== void 0 && QUOTES.has(quote)) {
1423
+ const end = closingQuote(value, quote, 1);
1424
+ if (end === -1) return { blank: false, opens: quote };
1425
+ const rest = value.slice(end + 1).trim();
1426
+ return { blank: end === 1 && (rest === "" || rest.startsWith("#")) };
1427
+ }
1428
+ const comment = value.indexOf("#");
1429
+ return { blank: (comment === -1 ? value : value.slice(0, comment)).trim() === "" };
1413
1430
  }
1414
1431
  function parseEnvFile(content) {
1415
1432
  const entries = [];
@@ -1418,15 +1435,15 @@ function parseEnvFile(content) {
1418
1435
  for (const [index, rawLine] of lines.entries()) {
1419
1436
  const line = rawLine.trim();
1420
1437
  if (closing !== void 0) {
1421
- if (line.endsWith(closing)) closing = void 0;
1438
+ if (closingQuote(line, closing, 0) !== -1) closing = void 0;
1422
1439
  continue;
1423
1440
  }
1424
1441
  if (line === "" || line.startsWith("#")) continue;
1425
1442
  const match = ENV_LINE.exec(line);
1426
1443
  if (!match) continue;
1427
- const value = match[2] ?? "";
1428
- entries.push({ key: match[1] ?? "", line: index + 1, blank: isBlankValue(value) });
1429
- closing = openQuote(value);
1444
+ const { blank, opens } = readValue(match[2] ?? "");
1445
+ entries.push({ key: match[1] ?? "", line: index + 1, blank });
1446
+ closing = opens;
1430
1447
  }
1431
1448
  return {
1432
1449
  entries,
@@ -2267,9 +2284,15 @@ function escapeData(text) {
2267
2284
  function escapeProperty(text) {
2268
2285
  return escapeData(text).replace(/:/g, "%3A").replace(/,/g, "%2C");
2269
2286
  }
2287
+ var GITHUB_ANNOTATION_CAP = 10;
2288
+ function summaryAnnotation(command, count, level) {
2289
+ const what = command === "error" ? "blocking findings" : level === "error" ? "advisory findings" : "findings";
2290
+ const message = `${count} ${what} (GitHub shows at most ${GITHUB_ANNOTATION_CAP} ${command} annotations per step): the full list is in the env report, in this step's log, and in env-report/env.md with --output markdown`;
2291
+ return `::${command} title=${escapeProperty(`env: ${count} ${what}`)}::${escapeData(message)}`;
2292
+ }
2270
2293
  function renderAnnotations(report) {
2271
2294
  if (report.level === "info") return [];
2272
- return report.findings.filter((entry) => entry.severity !== "info").map((entry) => {
2295
+ const annotations = report.findings.filter((entry) => entry.severity !== "info").map((entry) => {
2273
2296
  const command = report.level === "error" && entry.severity === "blocking" ? "error" : "warning";
2274
2297
  const properties = [
2275
2298
  entry.file === void 0 ? void 0 : `file=${escapeProperty(entry.file)}`,
@@ -2277,8 +2300,13 @@ function renderAnnotations(report) {
2277
2300
  `title=${escapeProperty(`env rule ${entry.number}: ${entry.rule}`)}`
2278
2301
  ].filter(Boolean);
2279
2302
  const message = `${entry.name}${entry.detail ? ` (${entry.detail})` : ""}: ${RULES[entry.rule].help}`;
2280
- return `::${command} ${properties.join(",")}::${escapeData(message)}`;
2303
+ return { command, line: `::${command} ${properties.join(",")}::${escapeData(message)}` };
2304
+ });
2305
+ const summaries = ["error", "warning"].flatMap((command) => {
2306
+ const count = annotations.filter((entry) => entry.command === command).length;
2307
+ return count > GITHUB_ANNOTATION_CAP ? [summaryAnnotation(command, count, report.level)] : [];
2281
2308
  });
2309
+ return [...summaries, ...annotations.map((entry) => entry.line)];
2282
2310
  }
2283
2311
 
2284
2312
  // src/roles/env/adapters/conformance/conformance.adapter.ts
@@ -2311,6 +2339,10 @@ function writeShapes(root, report, shapes) {
2311
2339
  return `${REPORT_DIR2}/${name}`;
2312
2340
  });
2313
2341
  }
2342
+ function oneLine(message) {
2343
+ const [head = "", ...rest] = message.split("\n").map((line) => line.trim().replace(/^- /, "")).filter((line) => line !== "");
2344
+ return rest.length === 0 ? head : `${head} ${rest.join("; ")}`;
2345
+ }
2314
2346
  var ConformanceAdapter = class extends BaseAdapter {
2315
2347
  target = TARGET2;
2316
2348
  runner = "conformance";
@@ -2345,17 +2377,17 @@ var ConformanceAdapter = class extends BaseAdapter {
2345
2377
  `writes ${ENV_SHAPES.join(", ")}; not ${unknown.join(", ")}, which another role may write.`
2346
2378
  );
2347
2379
  }
2380
+ const refuse = (message) => {
2381
+ this.say(message);
2382
+ return { ok: false, code: 1, metrics: { level, error: oneLine(message) } };
2383
+ };
2348
2384
  const { settings, from, unreadable } = await loadEnvConfig(ctx.cwd);
2349
- if (unreadable) {
2350
- this.say(unreadable);
2351
- return { ok: false, code: 1 };
2352
- }
2385
+ if (unreadable) return refuse(unreadable);
2353
2386
  let report;
2354
2387
  try {
2355
2388
  report = collectEnvReport(openRepository(ctx.cwd), settings, level, from);
2356
2389
  } catch (error) {
2357
- this.say(error instanceof Error ? error.message : String(error));
2358
- return { ok: false, code: 1 };
2390
+ return refuse(error instanceof Error ? error.message : String(error));
2359
2391
  }
2360
2392
  if (printing) {
2361
2393
  const written = writeShapes(ctx.cwd, report, known);
package/dist/index.js CHANGED
@@ -6,13 +6,13 @@ import {
6
6
  registerAdapters,
7
7
  resolve,
8
8
  setDefaultRunner
9
- } from "./chunk-PC5R4DX5.js";
9
+ } from "./chunk-5267A6X3.js";
10
10
  import {
11
11
  BaseAdapter
12
12
  } from "./chunk-P7NYSMLS.js";
13
13
  import "./chunk-JRZI5IIN.js";
14
14
  import "./chunk-7E6YTTAK.js";
15
- import "./chunk-NAFI7OLI.js";
15
+ import "./chunk-4EIY7TTR.js";
16
16
  import "./chunk-O7REVMOC.js";
17
17
  import "./chunk-QXFCZON7.js";
18
18
  export {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  defineEnv
3
- } from "../../chunk-NAFI7OLI.js";
3
+ } from "../../chunk-4EIY7TTR.js";
4
4
  export {
5
5
  defineEnv
6
6
  };
@@ -127,12 +127,42 @@ A repository that runs sentinel with `npx` cannot import it, since its own `node
127
127
  tried. An unknown key is refused by name, in both spellings: the file is `.mjs`, and an ignored
128
128
  `allowNonBlankedSecret` would silently stop downgrading the finding it was written for.
129
129
 
130
+ A refused config fails the run (exit 1, at every level) and says why twice: in full on stderr, one
131
+ line per problem, and on the summary row, `level=warning error=env.config.mjs: invalid env config:
132
+ unknown key "materialize"`, which is also the row's `error` in `--json`. A value of the wrong type is
133
+ named with what was found, its kind and never its content: `scan.roots[1] is an empty string`.
134
+
135
+ ### Not the monorepo's `repo-env.config.mjs`
136
+
137
+ `env.config.mjs` is sentinel's own schema, smaller than the monorepo's `tools/repo-env` config, and
138
+ not a copy of it. Reusing the monorepo's file as-is in another repository is not correct either way:
139
+ its allowlists, scan roots and readers describe the monorepo. A repository declares its own facts,
140
+ with these keys and no others: `externallyProvided`, `knownExternalVars`, `knownExternalPatterns`,
141
+ `allowNonBlankedSecrets`, `allowedCommittedEnvFiles`, `envFiles`, `contracts`, `scan` and
142
+ `references` (each `roots`, `ext`, `excludeSuffixes`, `excludeDirs`), `readers` (`required`,
143
+ `optional`) and `docker` (`allowRoot`, `excludePaths`).
144
+
145
+ Four keys of `repo-env.config.mjs` are refused, on purpose:
146
+
147
+ | `repo-env.config.mjs` key | in sentinel 1.6 |
148
+ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
149
+ | `materialize` | monorepo-only: it seeds that repository's test env files from its contracts, which is functional, not conformance. It stays in the monorepo |
150
+ | `references.patterns` | fixed in V1, built in: `process.env.X`, `process.env['X']`, `import.meta.env.X`, `import.meta.env['X']`, `$env/static/*` imports, `env.X`, `{ X } = env` |
151
+ | `secretNamePatterns` | fixed in V1, built in: `SECRET`, `TOKEN`, `PASSWORD`, `PRIVATE_?KEY`, `ACCESS_?KEY`, `API_?KEY`, case-insensitive |
152
+ | `publicEnvPrefixes` | fixed in V1, built in: `VITE_`, `PUBLIC_`, `NEXT_PUBLIC_`, `REACT_APP_` |
153
+
154
+ The three fixed ones describe how code reads env and what a name means, which do not vary by
155
+ repository. The migration of the monorepo onto the role is where they are revisited.
156
+
130
157
  ### Sentinel's defaults
131
158
 
132
159
  `presets/shared.json` in the role. Secret names match **case-insensitively** (`SECRET`, `TOKEN`,
133
160
  `PASSWORD`, `PRIVATE_?KEY`, `ACCESS_?KEY`, `API_?KEY`), so serviceapp's `facturationPassword` and a
134
161
  camel-case `apiKey` are seen. The scans read `.ts .tsx .mts .cts .js .jsx .mjs .cjs` (and `.svelte`,
135
- `.vue` for direct reads) across the whole repository, with test files left out of the reader scan.
162
+ `.vue` for direct reads) across the whole repository, with test files left out of the reader scan:
163
+ `.test.<ext>` and `.spec.<ext>` for each of those eight extensions. A value is read as dotenv reads
164
+ it: an unquoted value ends at its first `#` (dotenv's README, "Comments begin where a `#` exists"),
165
+ a quoted one at its closing quote, so `API_TOKEN= # filled by the vault` is blank.
136
166
 
137
167
  ## In CI
138
168
 
@@ -160,6 +190,10 @@ installs it for this step only.
160
190
  GitHub documents a cap of 10 warning and 10 error annotations per step
161
191
  (<https://docs.github.com/en/rest/checks/runs>): on a repository with more findings, the
162
192
  pull request shows only part of the annotations, and the markdown recap is the complete list.
193
+ Past the cap, sentinel still emits one annotation per finding, and emits FIRST, so it is always
194
+ shown, one summary annotation of the same type: at `error` on serviceapp, `55 blocking findings
195
+ (GitHub shows at most 10 error annotations per step): the full list is in the env report, ...`.
196
+ One per type at most, and none at or under 10.
163
197
 
164
198
  ## What changed from `tools/repo-env`
165
199
 
@@ -193,6 +227,11 @@ the same single difference.
193
227
  - Rule 6 reads every committed env file, not only contracts, and no contract at all is a note
194
228
  rather than a refusal to run.
195
229
  - Every finding has a line, so an annotation lands on it.
230
+ - A comment after a value is a comment, as in dotenv: `KEY= # note` is blank, not a committed
231
+ secret. And a quoted value followed by one, `KEY="" # note`, no longer opens a multi-line value
232
+ that hid every key after it in the file. The original read `KEY= # note` as filled.
233
+ - Test files are left out of the reader scan for every extension it reads; the original read
234
+ `.ts` and `.tsx` only, and left out `.spec.ts` and `.test.ts`.
196
235
 
197
236
  ## Outside the monorepo: what `tools/repo-env` assumed, and what the port does
198
237
 
@@ -141,8 +141,10 @@ Every cell is a test in `tests/e2e/env.e2e.test.ts`, against the built CLI, on g
141
141
  serviceapp-shaped repository (not nx, committed `variables/*.env`), the same with a package below
142
142
  its root, an nx workspace with a module, and a directory in no repository. `SENTINEL_ENV_LEVEL` and
143
143
  `GITHUB_ACTIONS` are pinned on every call, and "unset" removes the variable from the child's
144
- environment, which is a different input from the empty string. 52 cells: the 31 of the first draft,
145
- one more refused verb, 19 new ones, and the rule 1 note.
144
+ environment, which is a different input from the empty string. 57 cells: the 31 of the first draft,
145
+ one more refused verb, 19 new ones, the rule 1 note, and the 5 of the first adopter's feedback
146
+ (a refused config's row, in console and `--json`; test files of every extension; a comment after a
147
+ value; the summary annotation past GitHub's cap).
146
148
 
147
149
  ### verb, from the repository root
148
150
 
@@ -201,8 +203,17 @@ fails nothing would be decoration; none is, and every new cell fails under at le
201
203
  | `--json` annotations on stderr | 2 |
202
204
  | the rule 1 note, never emitted (unit + e2e) | 3 |
203
205
  | the rule 1 note, always emitted (unit + e2e) | 2 |
206
+ | test files of every extension (suffixes reverted) | 2 |
207
+ | a comment after a value (parser reverted) | 5 |
208
+ | the refusal's `error` on the row (adapter reverted) | 2 |
209
+ | no `undefined` key on the row (orchestrate reverted) | 1 |
210
+ | a refusal names the kind found (define reverted) | 4 |
211
+ | the summary annotation past the cap (report reverted) | 4 |
204
212
 
205
213
  The `COPY` fix is unit (`env-checks-fixes`): reverted, its 3 tests fail and the 66 others pass.
214
+ The six feedback controls ran unit and e2e together over the six env suites (187 tests, 193 for
215
+ the last one, once the summary cells existed): each failed only its own new cells, and no cell that existed before. The two
216
+ summary-annotation guards (nothing at exactly 10, nothing at `info`) pass reverted, as guards do.
206
217
 
207
218
  ### Against real repositories, from the packed tarball
208
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.6.0-alpha.0",
3
+ "version": "1.6.0-alpha.1",
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",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/roles/env/configs/define.ts"],"sourcesContent":["/**\n * `defineEnv`: what a repository writes to declare its own env facts, all of them optional.\n *\n * ```js\n * // env.config.mjs, at the repository root\n * import { defineEnv } from '@hublo/sentinel/env'\n *\n * export default defineEnv({\n * externallyProvided: ['NODE_ENV'],\n * allowNonBlankedSecrets: ['apps/front/x/.env.example:LEGACY_API_KEY'],\n * })\n * ```\n *\n * A repository that does not install sentinel, and runs it with `npx`, cannot import this module:\n * its own `node_modules` has no `@hublo/sentinel`. So the file may export the same object PLAIN,\n * and the role passes it through this function itself. Both spellings mean the same thing, and the\n * function exists for the one that can import it: it validates at the moment the file is written,\n * in an editor that knows the types.\n *\n * ## What a repository declares, and what stays sentinel's\n *\n * A repository declares FACTS about itself: which variables its CI injects, which committed value\n * was reviewed and is not a secret, which Dockerfile must run as root, where its code lives. It\n * does not redefine what a secret NAME looks like or how code reads env, which are the same in\n * every repository and ship in `presets/shared.json`.\n *\n * Two semantics, stated once:\n *\n * - `readers`, `envFiles` and `contracts` ADD to what sentinel ships, so no repository loses\n * `getOrFail` or `**\\/.env` by declaring a reader or a glob of its own;\n * - `scan` and `references` REPLACE, key by key: a repository naming its `roots` says where its\n * code is, and keeping `.` beside them would scan everything anyway.\n *\n * ⛔ An unknown key is refused, by name. The file is `.mjs`, so nothing type-checks it, and a key\n * spelled `allowNonBlankedSecret` would otherwise be ignored in silence while the finding it was\n * meant to downgrade kept failing the build.\n */\n\n/** A source scope a repository may narrow, every key optional. */\nexport interface EnvScopeInput {\n roots?: string[]\n ext?: string[]\n excludeSuffixes?: string[]\n excludeDirs?: string[]\n}\n\n/** What `env.config.mjs` exports. */\nexport interface EnvInput {\n /** Required variables in no contract because CI injects them or a secret store provides them. */\n externallyProvided?: string[]\n /** Declared variables consumed outside scannable source, so never reported dead (rule 4). */\n knownExternalVars?: string[]\n /** The same, by pattern, for a whole category such as `/_DATABASE_URL$/`. */\n knownExternalPatterns?: (RegExp | string)[]\n /** `<file>:<VAR>` locations whose committed value was reviewed and is not a secret (rule 6). */\n allowNonBlankedSecrets?: string[]\n /** Committed real env files permitted anyway, such as a test fixture (rule 2). */\n allowedCommittedEnvFiles?: string[]\n /** Globs of env files to read, ADDED to `**\\/.env`, `**\\/.env.*` and `**\\/*.env`. */\n envFiles?: string[]\n /** Globs of contracts, ADDED to `**\\/*.env*.example`. */\n contracts?: string[]\n /** Where the named readers are looked for (rules 1 and 3). Replaces, key by key. */\n scan?: EnvScopeInput\n /** Where direct `process.env` reads are looked for (rules 3 and 4). Replaces, key by key. */\n references?: EnvScopeInput\n /** Reader names ADDED to sentinel's: `required` throw when absent, `optional` default. */\n readers?: { required?: string[]; optional?: string[] }\n /** Docker rules: Dockerfiles allowed to run as root, and path prefixes not checked at all. */\n docker?: { allowRoot?: string[]; excludePaths?: string[] }\n}\n\nconst TOP_LEVEL_KEYS = new Set<string>([\n 'externallyProvided',\n 'knownExternalVars',\n 'knownExternalPatterns',\n 'allowNonBlankedSecrets',\n 'allowedCommittedEnvFiles',\n 'envFiles',\n 'contracts',\n 'scan',\n 'references',\n 'readers',\n 'docker',\n])\nconst SCOPE_KEYS = new Set(['roots', 'ext', 'excludeSuffixes', 'excludeDirs'])\nconst NESTED_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {\n scan: SCOPE_KEYS,\n references: SCOPE_KEYS,\n readers: new Set(['required', 'optional']),\n docker: new Set(['allowRoot', 'excludePaths']),\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** Every problem with one list: it must be an array of non-empty strings. */\nfunction stringListProblems(path: string, value: unknown): string[] {\n if (value === undefined) return []\n if (!Array.isArray(value)) return [`${path} must be an array of strings`]\n return value.some((entry) => typeof entry !== 'string' || entry === '')\n ? [`${path} must hold non-empty strings only`]\n : []\n}\n\n/** Every problem with the input, each naming the key, so one run lists them all. */\nfunction problemsOf(input: Record<string, unknown>): string[] {\n const problems: string[] = []\n for (const [key, value] of Object.entries(input)) {\n if (!TOP_LEVEL_KEYS.has(key)) {\n problems.push(`unknown key \"${key}\"`)\n continue\n }\n const nested = NESTED_KEYS[key]\n if (nested === undefined) {\n if (key === 'knownExternalPatterns') {\n if (value !== undefined && !Array.isArray(value)) {\n problems.push(`${key} must be an array of RegExp or strings`)\n } else if (\n Array.isArray(value) &&\n value.some((entry) => !(entry instanceof RegExp) && typeof entry !== 'string')\n ) {\n problems.push(`${key} must hold RegExp or strings only`)\n }\n } else {\n problems.push(...stringListProblems(key, value))\n }\n continue\n }\n if (value === undefined) continue\n if (!isRecord(value)) {\n problems.push(`${key} must be an object`)\n continue\n }\n for (const [inner, innerValue] of Object.entries(value)) {\n if (!nested.has(inner)) problems.push(`unknown key \"${key}.${inner}\"`)\n else problems.push(...stringListProblems(`${key}.${inner}`, innerValue))\n }\n }\n return problems\n}\n\n/**\n * Validate a repository's env declaration and return it unchanged.\n *\n * The defaults are NOT merged in here. They ship with sentinel and are applied when the role runs,\n * so changing one reaches every repository at its next sentinel version instead of leaving a copy\n * frozen in each config. That also makes this function idempotent, which is what lets the role\n * pass a plain exported object through it a second time.\n *\n * @param input the declaration; omitted, the repository declares nothing\n * @returns the same declaration\n * @throws naming every unknown key and every malformed value at once\n */\nexport function defineEnv(input: EnvInput = {}): EnvInput {\n if (!isRecord(input)) throw new Error('env config must export an object')\n const problems = problemsOf(input)\n if (problems.length > 0) {\n throw new Error(`invalid env config:\\n - ${problems.join('\\n - ')}`)\n }\n return input\n}\n"],"mappings":";AAwEA,IAAM,iBAAiB,oBAAI,IAAY;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AACD,IAAM,aAAa,oBAAI,IAAI,CAAC,SAAS,OAAO,mBAAmB,aAAa,CAAC;AAC7E,IAAM,cAA6D;AAAA,EACjE,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,SAAS,oBAAI,IAAI,CAAC,YAAY,UAAU,CAAC;AAAA,EACzC,QAAQ,oBAAI,IAAI,CAAC,aAAa,cAAc,CAAC;AAC/C;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAGA,SAAS,mBAAmB,MAAc,OAA0B;AAClE,MAAI,UAAU,OAAW,QAAO,CAAC;AACjC,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC,GAAG,IAAI,8BAA8B;AACxE,SAAO,MAAM,KAAK,CAAC,UAAU,OAAO,UAAU,YAAY,UAAU,EAAE,IAClE,CAAC,GAAG,IAAI,mCAAmC,IAC3C,CAAC;AACP;AAGA,SAAS,WAAW,OAA0C;AAC5D,QAAM,WAAqB,CAAC;AAC5B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,CAAC,eAAe,IAAI,GAAG,GAAG;AAC5B,eAAS,KAAK,gBAAgB,GAAG,GAAG;AACpC;AAAA,IACF;AACA,UAAM,SAAS,YAAY,GAAG;AAC9B,QAAI,WAAW,QAAW;AACxB,UAAI,QAAQ,yBAAyB;AACnC,YAAI,UAAU,UAAa,CAAC,MAAM,QAAQ,KAAK,GAAG;AAChD,mBAAS,KAAK,GAAG,GAAG,wCAAwC;AAAA,QAC9D,WACE,MAAM,QAAQ,KAAK,KACnB,MAAM,KAAK,CAAC,UAAU,EAAE,iBAAiB,WAAW,OAAO,UAAU,QAAQ,GAC7E;AACA,mBAAS,KAAK,GAAG,GAAG,mCAAmC;AAAA,QACzD;AAAA,MACF,OAAO;AACL,iBAAS,KAAK,GAAG,mBAAmB,KAAK,KAAK,CAAC;AAAA,MACjD;AACA;AAAA,IACF;AACA,QAAI,UAAU,OAAW;AACzB,QAAI,CAAC,SAAS,KAAK,GAAG;AACpB,eAAS,KAAK,GAAG,GAAG,oBAAoB;AACxC;AAAA,IACF;AACA,eAAW,CAAC,OAAO,UAAU,KAAK,OAAO,QAAQ,KAAK,GAAG;AACvD,UAAI,CAAC,OAAO,IAAI,KAAK,EAAG,UAAS,KAAK,gBAAgB,GAAG,IAAI,KAAK,GAAG;AAAA,UAChE,UAAS,KAAK,GAAG,mBAAmB,GAAG,GAAG,IAAI,KAAK,IAAI,UAAU,CAAC;AAAA,IACzE;AAAA,EACF;AACA,SAAO;AACT;AAcO,SAAS,UAAU,QAAkB,CAAC,GAAa;AACxD,MAAI,CAAC,SAAS,KAAK,EAAG,OAAM,IAAI,MAAM,kCAAkC;AACxE,QAAM,WAAW,WAAW,KAAK;AACjC,MAAI,SAAS,SAAS,GAAG;AACvB,UAAM,IAAI,MAAM;AAAA,MAA4B,SAAS,KAAK,QAAQ,CAAC,EAAE;AAAA,EACvE;AACA,SAAO;AACT;","names":[]}