enigma-cli 1.33.6 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/assets/memory/AGENTS.md +2 -0
  2. package/assets/memory/CLAUDE.md +2 -0
  3. package/assets/skills/anti-overengineering-policy/skill.json +1 -1
  4. package/assets/skills/anti-overengineering-review/skill.json +1 -1
  5. package/assets/skills/backend-policy/SKILL.md +39 -2
  6. package/assets/skills/backend-policy/skill.json +5 -5
  7. package/assets/skills/ciphera-style-policy/SKILL.md +26 -1
  8. package/assets/skills/ciphera-style-policy/skill.json +5 -5
  9. package/assets/skills/code-review-policy/SKILL.md +2 -0
  10. package/assets/skills/code-review-policy/skill.json +4 -4
  11. package/assets/skills/core-engineering-policy/SKILL.md +19 -1
  12. package/assets/skills/core-engineering-policy/skill.json +5 -5
  13. package/assets/skills/database-expert/SKILL.md +19 -1
  14. package/assets/skills/database-expert/skill.json +4 -4
  15. package/assets/skills/debugging-policy/SKILL.md +2 -1
  16. package/assets/skills/debugging-policy/skill.json +4 -4
  17. package/assets/skills/dependency-policy/skill.json +1 -1
  18. package/assets/skills/email-policy/skill.json +1 -1
  19. package/assets/skills/frontend-design/skill.json +1 -1
  20. package/assets/skills/frontend-policy/SKILL.md +35 -5
  21. package/assets/skills/frontend-policy/skill.json +4 -4
  22. package/assets/skills/git-policy/skill.json +1 -1
  23. package/assets/skills/logo-sourcing-policy/skill.json +1 -1
  24. package/assets/skills/security-policy/SKILL.md +1 -0
  25. package/assets/skills/security-policy/skill.json +4 -4
  26. package/assets/skills/skill-creator/assets/eval_review.html +3 -1
  27. package/assets/skills/skill-creator/eval-viewer/viewer.html +1 -0
  28. package/assets/skills/skill-creator/skill.json +3 -3
  29. package/assets/skills/task-completion-policy/SKILL.md +1 -0
  30. package/assets/skills/task-completion-policy/skill.json +4 -4
  31. package/assets/skills/technical-writing-policy/skill.json +1 -1
  32. package/assets/skills/testing-policy/skill.json +1 -1
  33. package/assets/skills/validation-policy/skill.json +1 -1
  34. package/bin/checksums.json +4 -4
  35. package/dist/guardrails.js +522 -12
  36. package/package.json +1 -1
@@ -4,8 +4,8 @@
4
4
  import { homedir } from "os";
5
5
  import { fileURLToPath } from "url";
6
6
  import { execFileSync } from "child_process";
7
- import { dirname, join, resolve } from "path";
8
- import { readFileSync, writeFileSync, statSync, existsSync } from "fs";
7
+ import { dirname, join, resolve, sep } from "path";
8
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, existsSync } from "fs";
9
9
  var COMMENT_LINE = /^\s*(\/\/|#|\*|--|<!--|\{?\/\*)/;
10
10
  var BUILTIN_RULES = [
11
11
  {
@@ -419,8 +419,46 @@ var BUILTIN_RULES = [
419
419
  // precision (a multi-line block is not matched: precision > recall).
420
420
  pattern: "\\bif\\s*\\(\\s*(isLoading|isPending|isFetching|loading|pending)\\s*\\)\\s*return\\s+(null\\b|<\\s*\\w*(Spinner|Loader|Loading|CircularProgress)\\b)",
421
421
  absent: "skeleton|animate-pulse|shimmer|Suspense|ContentLoader|content-loader|<\\s*Placeholder",
422
- message: "Component returns nothing (or only a spinner) while data loads, so the page stays blank until the fetch resolves. Render the shell/layout on first paint and show skeleton placeholders shaped like the final content (reserve their space to avoid layout shift) while data loads async via the API (frontend-policy).",
423
- severity: "warn",
422
+ message: "Component returns nothing (or only a spinner) while data loads, so the whole page stays blank until the fetch resolves. Render the shell on first paint - nav, headings, card frames, table chrome, filters, and any value you already hold - and skeleton ONLY the region whose data is missing, shaped like the real content with its space reserved so nothing shifts when it lands. A region that does not depend on this request is not loading and must render now (frontend-policy).",
423
+ // BLOCK, changed from warn: this is the rule for the defect users keep reporting (a page
424
+ // that renders nothing until its data arrives), and as a warn it exited 0 - printed to
425
+ // stdout and never fed back to the model, which is precisely why the model kept writing
426
+ // it. Same reasoning as ui-no-em-dash. The pattern is a terse one-line guard cleared by
427
+ // any placeholder signal in the file, so there is no legacy backlog to flag.
428
+ severity: "block",
429
+ skill: "frontend-policy"
430
+ },
431
+ {
432
+ id: "fe-server-first-mutation",
433
+ label: "Optimistic update for a reversible mutation",
434
+ files: ["*.tsx", "*.jsx", "*.vue", "*.svelte"],
435
+ excludeFiles: ["*.test.*", "*.spec.*", "**/tests/**", "**/__tests__/**", "**/dist/**", "dist/**", "**/build/**", "build/**", "**/.next/**", ".next/**", "**/node_modules/**"],
436
+ scope: "file",
437
+ // DIFF stage, and that is the whole reason this rule can exist. Measured over 2762 UI files
438
+ // of real product repositories, 116 of the 140 files that mutate anything hold no optimistic
439
+ // update at all - a legacy backlog an edit-stage rule would fire on forever, which is how a
440
+ // gate teaches people to ignore it. Against the lines a turn ADDED there is no backlog: it
441
+ // fires only on a mutation the agent just wrote.
442
+ stage: "diff",
443
+ fileCheck: "fe-server-first-mutation",
444
+ message: "This mutation waits for the server before it touches the UI: the row is only dropped (or the flag only flipped) after the request resolves, so the interface freezes for the whole round trip on an action that cannot really fail in an interesting way. Apply the change to local state FIRST, then send the request, and on failure restore the value you saved and say what failed - a silent revert is worse than the wait. Where the delete is reversible, prefer acting immediately with an Undo affordance over a confirmation prompt. Mark the line `enigma:allow-server-first` when the server's response is genuinely required before the UI may change (a payment, a server-assigned identifier, an irreversible action) (frontend-policy).",
445
+ severity: "block",
446
+ skill: "frontend-policy"
447
+ },
448
+ {
449
+ id: "fe-textarea-size-bounds",
450
+ label: "Textarea declares a minimum and a maximum size",
451
+ files: ["*.tsx", "*.jsx", "*.vue", "*.svelte", "*.astro", "*.html", "*.htm"],
452
+ excludeFiles: ["*.test.*", "*.spec.*", "**/tests/**", "**/__tests__/**", "**/dist/**", "dist/**", "**/build/**", "build/**", "**/.next/**", ".next/**", "**/node_modules/**"],
453
+ scope: "file",
454
+ // DIFF stage, for the reason the measurement gave: 17 corpus textarea sites carry no upper
455
+ // bound, so an edit-stage rule would report a project's existing forms on every unrelated
456
+ // edit to the same file. Against the lines a change adds there is no backlog, and the rule
457
+ // can be as strict as the convention actually is.
458
+ stage: "diff",
459
+ fileCheck: "fe-textarea-size-bounds",
460
+ message: "A textarea is the only input the user can resize, so it is the only one that can break a layout after it has rendered. Give it both bounds: `rows` (or a min-height) so it never collapses below a usable size, and a max-height so dragging it - or letting it grow with its content - cannot push the page apart. Prefer `resize: vertical` so the column width survives, and put the bounds on the shared Textarea component rather than on each usage. Mark the line `enigma:allow-unbounded-textarea` when the surface owns its viewport (a full-page editor, a code surface) or the design calls for something else (frontend-policy).",
461
+ severity: "block",
424
462
  skill: "frontend-policy"
425
463
  },
426
464
  {
@@ -628,6 +666,21 @@ var BUILTIN_RULES = [
628
666
  // precise signature (a dirty/hasChanges flag assigned a literal true) returned 0 real
629
667
  // hits and 2 false ones, a CLI tracking whether it had rewritten a config file. A rule
630
668
  // here would fire on correct code, so it stays guidance in frontend-policy.
669
+ // NOTE: there is deliberately no rule for "a server component awaits its data before it
670
+ // returns markup". It is the other half of the blank-first-paint complaint, but the shape
671
+ // (`export default async function Page()` with an awaited value and no Suspense boundary) is
672
+ // ALSO how a correct statically-generated page is written - the corpus's one match is a docs
673
+ // page awaiting its MDX at build time, where there is no runtime wait to hide. Whether the
674
+ // await costs the user anything depends on where the page renders and whether the data is
675
+ // static, and none of that is in the file. It stays in frontend-policy's Instant First Paint.
676
+ // NOTE: there is deliberately no rule for "a fixed-length one-time code submits itself when
677
+ // the last digit lands". The selector would be precise (`autocomplete="one-time-code"`, the
678
+ // same marker sec-password-breach-check keys on), but the DEFECT has no file-local form: the
679
+ // submit normally lives in a parent, a form library or a mutation hook, so its absence from
680
+ // the field's file proves nothing, and the three guards that make auto-submit safe (once per
681
+ // distinct complete value, no re-fire after a failure, no auto-retry on 429) are behaviour a
682
+ // regex cannot read at all. It stays in frontend-policy's auth section, with the attempt-cap
683
+ // half in security-policy.
631
684
  // NOTE: there is deliberately no "card inside a card" or "border with no information"
632
685
  // rule, even though both are named in frontend-policy. They are RELATIONAL defects: a
633
686
  // container is redundant only relative to the ancestor it sits in and the spacing around
@@ -697,6 +750,170 @@ var BUILTIN_RULES = [
697
750
  severity: "block",
698
751
  skill: "ciphera-style-policy"
699
752
  },
753
+ {
754
+ id: "fe-icon-shrink",
755
+ label: "An icon does not shrink to make room for text",
756
+ files: ["*.css", "*.scss", "*.html", "*.htm", "*.astro", "*.vue", "*.svelte", "*.tsx", "*.jsx"],
757
+ excludeFiles: [
758
+ "*.test.*",
759
+ "*.spec.*",
760
+ "**/tests/**",
761
+ "**/__tests__/**",
762
+ "**/stories/**",
763
+ "*.stories.*",
764
+ "*.min.css",
765
+ "**/dist/**",
766
+ "**/build/**",
767
+ "**/node_modules/**",
768
+ "**/vendor/**",
769
+ "dist/**",
770
+ "build/**",
771
+ "node_modules/**",
772
+ "vendor/**"
773
+ ],
774
+ scope: "file",
775
+ // `flex-shrink: 1` is the default, so in a row of icon plus text the browser takes width
776
+ // from BOTH when the text runs long - and the icon, having no content to reflow, is simply
777
+ // squashed. An explicit width/height does not protect it (that is the base size, not the
778
+ // minimum) and an <svg> scales with its viewBox rather than clipping, so it deforms
779
+ // silently instead of overflowing visibly.
780
+ // Two gateable shapes, one per styling model. (a) A STYLESHEET rule sizing an svg/img on
781
+ // one line: the size bound (<= 64px) is what makes it an ICON rather than a picture -
782
+ // a hero image at 640px SHOULD shrink with the viewport, and pinning it would be the
783
+ // wrong fix. (b) A UTILITY-CLASS line carrying a flex container, an icon element and an
784
+ // icon size class together; the flex requirement is what keeps this off the rest of the
785
+ // markup, and it is why a multi-line JSX icon is out of reach by construction (the same
786
+ // accepted recall loss as fe-icon-action-button). Case-SENSITIVE so `[A-Z]\w*` means a
787
+ // component tag (<ExternalLink>, <Avatar>) and not every lowercase element.
788
+ pattern: "^(?!.*enigma:)(?:(?=.*\\bflex\\b)(?=.*<(?:svg|img|[A-Z][A-Za-z0-9]*)\\b).*\\b(?:h-\\d(?:\\.\\d)?[ \\t]+w-\\d(?:\\.\\d)?|w-\\d(?:\\.\\d)?[ \\t]+h-\\d(?:\\.\\d)?|size-\\d(?:\\.\\d)?)\\b|[^{}/]*\\b(?:svg|img)[ \\t]*(?:,[^{}]*)?\\{[^}]*\\bwidth:[ \\t]*(?:[1-9]|[1-5]\\d|6[0-4])px)",
789
+ flags: "",
790
+ // A file that already pins an icon anywhere is treated as having made the decision. This
791
+ // is deliberately leaky (one guarded rule clears the file) because the fix that scales is
792
+ // a single base rule - `svg { flex-shrink: 0 }` - not a repetition per selector, and a
793
+ // rule that kept firing after that fix would train the model to ignore it.
794
+ absent: "flex-shrink:\\s*0|\\bshrink-0\\b|\\bflex-none\\b|flex:\\s*(?:none|0 0)|enigma:allow-shrinking-icon",
795
+ message: "Icon sized but not pinned. `flex-shrink: 1` is the default, so when the text beside it runs long the browser takes width from the ICON too - and having no content to reflow, a 14px glyph ends up rendered 4px wide next to a long name. The explicit width/height does not prevent it: that is the base size, not the minimum, and an svg scales with its viewBox instead of clipping, so it deforms silently. Give anything with a fixed intrinsic size - icon, avatar, badge, status dot, spinner - `flex-shrink: 0` (Tailwind `shrink-0`), and let the TEXT be what truncates. Set it once where the icons are defined (`svg { flex-shrink: 0 }` in the base stylesheet, or inside the shared Icon component) rather than per row. Mark a deliberate exception with an `enigma:` note on the line or `enigma:allow-shrinking-icon` in the file (frontend-policy).",
796
+ severity: "block",
797
+ skill: "frontend-policy"
798
+ },
799
+ // TYPESCRIPT MODULE GRAPH. Three rules that keep a TS project's imports stable as it grows:
800
+ // the project declares an alias, deep climbs go through it, and no specifier carries a build
801
+ // artifact's extension. All three are decided against the project's tsconfig rather than the
802
+ // edited line, which is why each is a coded check (see the module-graph block below).
803
+ {
804
+ id: "ts-alias-paths",
805
+ label: "TypeScript project declares a path alias",
806
+ // The exact basename only: the split configs a bundler generates (tsconfig.node.json,
807
+ // tsconfig.app.json) exist to compile one config file and have no source tree to alias.
808
+ files: ["tsconfig.json"],
809
+ excludeFiles: [
810
+ "**/node_modules/**",
811
+ "**/dist/**",
812
+ "**/build/**",
813
+ "**/vendor/**",
814
+ "node_modules/**",
815
+ "dist/**",
816
+ "build/**",
817
+ "vendor/**"
818
+ ],
819
+ scope: "file",
820
+ fileCheck: "ts-alias-paths",
821
+ message: 'This TypeScript project declares no path alias. Add one - `"baseUrl": "."` plus `"paths": { "@/*": ["./src/*"] }` - and import through it (`@/services/user`) instead of counting directories. A relative chain encodes where the importing file happens to sit, so moving either file rewrites specifiers that had nothing to do with the change; an alias is stable under both. Bundlers, tsx and Bun resolve it from tsconfig with no extra config; for Jest add moduleNameMapper. If this config is not the project\'s source config, mark it with an `enigma:` note (ciphera-style-policy).',
822
+ severity: "block",
823
+ skill: "ciphera-style-policy"
824
+ },
825
+ {
826
+ id: "ts-alias-deep-relative",
827
+ label: "Deep relative import goes through the path alias",
828
+ files: ["*.ts", "*.tsx", "*.mts", "*.cts"],
829
+ // Tests are excluded on purpose: a runner that has not been told about the alias (Jest
830
+ // without moduleNameMapper) cannot resolve it, so the import that is right in src is not
831
+ // automatically right in a test file. Same two-form generated/vendored excludes as above.
832
+ excludeFiles: [
833
+ "*.test.*",
834
+ "*.spec.*",
835
+ "**/tests/**",
836
+ "**/__tests__/**",
837
+ "**/fixtures/**",
838
+ "*.d.ts",
839
+ "**/dist/**",
840
+ "**/build/**",
841
+ "**/_build/**",
842
+ "**/node_modules/**",
843
+ "**/vendor/**",
844
+ "dist/**",
845
+ "build/**",
846
+ "_build/**",
847
+ "node_modules/**",
848
+ "vendor/**"
849
+ ],
850
+ scope: "file",
851
+ // Fires only when the project HAS an alias covering the target: the climb on its own is
852
+ // correct code in a project with none, and a target outside the aliased root cannot be
853
+ // written any other way. Measured over the corpus: every project that declares an alias
854
+ // already uses it everywhere, so this is a scaffolding guard, not a backlog.
855
+ fileCheck: "ts-alias-deep-relative",
856
+ message: "Deep relative import in a project that declares a path alias. Write it through the alias instead: the chain of `../` names the directory the importing file sits in today, so moving either file breaks specifiers that had nothing to do with the change, and a reader has to count directories to see what is being imported. Keep `./sibling` and `../` for a file in the same or the parent folder - the alias is for anything further. Mark a deliberate exception with an `enigma:` note on the line (ciphera-style-policy).",
857
+ severity: "block",
858
+ skill: "ciphera-style-policy"
859
+ },
860
+ {
861
+ id: "ts-import-extension",
862
+ label: "No file extension in a module specifier",
863
+ files: ["*.ts", "*.tsx"],
864
+ // .mts/.cts are out of scope by construction: those extensions exist to pin a file to
865
+ // Node's dual-module resolution, where the specifier extension is mandatory.
866
+ excludeFiles: [
867
+ "*.d.ts",
868
+ "**/dist/**",
869
+ "**/build/**",
870
+ "**/_build/**",
871
+ "**/node_modules/**",
872
+ "**/vendor/**",
873
+ "dist/**",
874
+ "build/**",
875
+ "_build/**",
876
+ "node_modules/**",
877
+ "vendor/**"
878
+ ],
879
+ scope: "file",
880
+ // Only under bundler/preserve resolution, and only when no such file actually exists -
881
+ // see extensionImports for why both guards are what keep this at zero false positives.
882
+ fileCheck: "ts-import-extension",
883
+ message: 'File extension in a module specifier. Under `"moduleResolution": "bundler"` the resolver finds the source file on its own, so an extension only pins the import to a build artifact - `.js` names a file that does not exist in the source tree, and `.ts` needs allowImportingTsExtensions and breaks the moment the project emits. Drop it and let the resolver do the work. If this project has to emit for Node\'s own ESM resolution instead, that is a tsconfig decision (`"module": "nodenext"`), and there the extension is required - make it once in tsconfig rather than per import (backend-policy, ciphera-style-policy).',
884
+ severity: "block",
885
+ skill: "ciphera-style-policy"
886
+ },
887
+ {
888
+ id: "ts-legacy-module-resolution",
889
+ label: "Modern TypeScript module resolution and target",
890
+ files: ["tsconfig.json", "tsconfig.*.json"],
891
+ excludeFiles: [
892
+ "**/node_modules/**",
893
+ "**/dist/**",
894
+ "**/build/**",
895
+ "**/vendor/**",
896
+ "node_modules/**",
897
+ "dist/**",
898
+ "build/**",
899
+ "vendor/**"
900
+ ],
901
+ scope: "file",
902
+ // `node`/`node10` is TypeScript's own legacy resolver: it predates package.json "exports",
903
+ // so a modern dependency resolves to the wrong entry point or not at all. A pre-ES2017
904
+ // target is the same class of decision - it downlevels async/await itself. Both are
905
+ // single, unambiguous values, which is what makes this a pattern rule rather than a
906
+ // check; a project that genuinely needs ES5 output marks the line.
907
+ // THE TARGET BOUND IS DELIBERATELY LOWER THAN THE ADVICE. backend-policy asks for es2022,
908
+ // but `"target": "es2017"` is what create-next-app still ships and what several stock
909
+ // configs default to, and in a Next app SWC compiles the output anyway so the value
910
+ // barely matters - blocking the ecosystem's own template is how a rule teaches people to
911
+ // ignore it. The skill persuades toward es2022; the gate only stops what is unambiguous.
912
+ pattern: `^(?!.*enigma:).*(?:["']moduleResolution["']\\s*:\\s*["']node(?:10)?["']|["']target["']\\s*:\\s*["']es(?:3|5|6|2015|2016)["'])`,
913
+ message: 'Legacy TypeScript configuration. `"moduleResolution": "node"` is the pre-2022 resolver: it ignores a package\'s `exports` map, so a modern dependency resolves to the wrong entry point or not at all, and a pre-ES2017 target downlevels async/await itself. For a backend built by a bundler or run by tsx/Bun use `"module": "esnext"` with `"moduleResolution": "bundler"`; for one emitted by tsc for Node\'s own loader use `"module": "nodenext"` (and then specifiers DO carry `.js`). Pair either with `"target": "es2022"` and `"strict": true`. Mark a deliberate legacy target with an `enigma:` note on the line (backend-policy).',
914
+ severity: "block",
915
+ skill: "backend-policy"
916
+ },
700
917
  {
701
918
  id: "proc-windows-hide",
702
919
  label: "Spawned process must not pop a console window",
@@ -1011,7 +1228,12 @@ var PROJECT_CHECKS = {
1011
1228
  }
1012
1229
  };
1013
1230
  var FILE_CHECKS = {
1014
- "proc-windows-hide": (content) => missingWindowsHide(content)
1231
+ "proc-windows-hide": (content) => missingWindowsHide(content),
1232
+ "fe-server-first-mutation": (content) => serverFirstMutation(content),
1233
+ "fe-textarea-size-bounds": (content) => textareaSizeBounds(content),
1234
+ "ts-import-extension": (content, file) => extensionImports(content, file),
1235
+ "ts-alias-deep-relative": (content, file) => deepRelativeImports(content, file),
1236
+ "ts-alias-paths": (content, file) => missingPathAlias(content, file)
1015
1237
  };
1016
1238
  var FIXERS = {
1017
1239
  "fe-name-input-capitalize": (line, file) => {
@@ -1082,6 +1304,180 @@ function missingWindowsHide(content) {
1082
1304
  }
1083
1305
  return out;
1084
1306
  }
1307
+ var MUTATING_REQUEST = /method:\s*["'`](POST|PUT|PATCH|DELETE)|\b(?:axios|api|\$fetch|http|client)\.(?:post|put|patch|delete)\s*\(/i;
1308
+ var ENTITY_WRITE = /\bset[A-Z]\w*\s*\(\s*(?:\(?\w+\)?\s*=>\s*)?[\w.]*\.(?:filter|map|slice|concat)\s*\(|\bset[A-Z]\w*\s*\(\s*!/;
1309
+ var OPTIMISTIC_SIGNAL = /useOptimistic|onMutate|optimisticData|setQueryData|rollbackOnError|\brollback\s*[(:]|\brevert\s*[(:]|previous[A-Z_]/;
1310
+ var ALLOW_SERVER_FIRST = /enigma:allow-server-first/;
1311
+ var RESULT_BINDING = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?/;
1312
+ var BLOCK_LOOKBACK = 120;
1313
+ var BLOCK_LOOKAHEAD = 200;
1314
+ function enclosingBlock(lines, index) {
1315
+ let depth = 0;
1316
+ let start = index;
1317
+ let foundStart = false;
1318
+ for (let i = index; i >= 0 && index - i < BLOCK_LOOKBACK && !foundStart; i--) {
1319
+ const line = lines[i];
1320
+ for (let c = line.length - 1; c >= 0; c--) {
1321
+ if (line[c] === "}") depth++;
1322
+ else if (line[c] === "{") {
1323
+ if (depth === 0) {
1324
+ start = i;
1325
+ foundStart = true;
1326
+ break;
1327
+ }
1328
+ depth--;
1329
+ }
1330
+ }
1331
+ }
1332
+ depth = 0;
1333
+ let end = index;
1334
+ let foundEnd = false;
1335
+ for (let i = start; i < lines.length && i - start < BLOCK_LOOKAHEAD && !foundEnd; i++) {
1336
+ for (const ch of lines[i]) {
1337
+ if (ch === "{") depth++;
1338
+ else if (ch === "}") {
1339
+ depth--;
1340
+ if (depth === 0) {
1341
+ end = i;
1342
+ foundEnd = true;
1343
+ break;
1344
+ }
1345
+ }
1346
+ }
1347
+ }
1348
+ return { start, end: Math.max(end, index) };
1349
+ }
1350
+ function serverFirstMutation(content) {
1351
+ if (OPTIMISTIC_SIGNAL.test(content)) return [];
1352
+ const lines = content.split("\n");
1353
+ const out = [];
1354
+ for (let i = 0; i < lines.length; i++) {
1355
+ const line = lines[i];
1356
+ if (COMMENT_LINE.test(line) || !MUTATING_REQUEST.test(line)) continue;
1357
+ const { start, end } = enclosingBlock(lines, i);
1358
+ if (lines.slice(start, end + 1).some((l) => ALLOW_SERVER_FIRST.test(l))) continue;
1359
+ if (lines.slice(start, i).some((l) => ENTITY_WRITE.test(l))) continue;
1360
+ let binding = "";
1361
+ for (let b = i; b >= Math.max(0, i - 4) && !binding; b--) binding = RESULT_BINDING.exec(lines[b])?.[1] ?? "";
1362
+ const uses = binding ? new RegExp(`\\b${binding}\\b`) : null;
1363
+ const write = lines.slice(i + 1, end + 1).find((l) => {
1364
+ if (COMMENT_LINE.test(l)) return false;
1365
+ const at = ENTITY_WRITE.exec(l);
1366
+ if (!at) return false;
1367
+ return !uses?.test(l.slice(at.index));
1368
+ });
1369
+ if (write) out.push({ line: i + 1, detail: `the UI is only updated after the request resolves: ${write.trim().slice(0, 80)}` });
1370
+ }
1371
+ return out;
1372
+ }
1373
+ var TEXTAREA = /<textarea\b/;
1374
+ var TEXTAREA_LOWER = /\brows\s*=|\brows:\s*\d|min-h-|min-height|minHeight|\bh-\[|\bh-\d|height\s*:\s*\d/;
1375
+ var TEXTAREA_UPPER = /max-h-|max-height|maxHeight/;
1376
+ var TEXTAREA_FIXED = /resize-none|resize\s*:\s*none/;
1377
+ var TEXTAREA_AUTOSIZE = /field-?sizing|scrollHeight|autosize|auto-size|TextareaAutosize|textarea-autosize/i;
1378
+ function textareaSizeBounds(content) {
1379
+ const lower = TEXTAREA_LOWER.test(content);
1380
+ const upper = TEXTAREA_UPPER.test(content);
1381
+ const fixed = TEXTAREA_FIXED.test(content) && !TEXTAREA_AUTOSIZE.test(content);
1382
+ if (lower && (upper || fixed)) return [];
1383
+ const out = [];
1384
+ const lines = content.split("\n");
1385
+ for (let i = 0; i < lines.length; i++) {
1386
+ const line = lines[i];
1387
+ if (COMMENT_LINE.test(line) || /enigma:/.test(line) || !TEXTAREA.test(line)) continue;
1388
+ const missing = [];
1389
+ if (!lower) missing.push("no minimum size: no rows, min-height or fixed height");
1390
+ if (!upper && !fixed) missing.push("no maximum size: no max-height, and it can be dragged or grows with its content");
1391
+ if (missing.length) out.push({ line: i + 1, detail: missing.join("; ") });
1392
+ }
1393
+ return out;
1394
+ }
1395
+ var SPECIFIER = /^[ \t]*(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']|^[ \t]*import\s*["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']|\brequire\(\s*["']([^"']+)["']/gm;
1396
+ var MODULE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/i;
1397
+ var JS_EXT = /\.(js|jsx|mjs|cjs)$/i;
1398
+ var tsconfigCache = /* @__PURE__ */ new Map();
1399
+ function nearestTsconfig(file) {
1400
+ let dir = dirname(resolve(file));
1401
+ const seen = [];
1402
+ for (let i = 0; i < 20; i++) {
1403
+ const cached = tsconfigCache.get(dir);
1404
+ if (cached !== void 0) {
1405
+ for (const d of seen) tsconfigCache.set(d, cached);
1406
+ return cached;
1407
+ }
1408
+ seen.push(dir);
1409
+ const candidate = join(dir, "tsconfig.json");
1410
+ if (existsSync(candidate)) {
1411
+ let found = null;
1412
+ try {
1413
+ found = { dir, text: readFileSync(candidate, "utf8") };
1414
+ } catch {
1415
+ found = null;
1416
+ }
1417
+ for (const d of seen) tsconfigCache.set(d, found);
1418
+ return found;
1419
+ }
1420
+ const parent = dirname(dir);
1421
+ if (parent === dir) break;
1422
+ dir = parent;
1423
+ }
1424
+ for (const d of seen) tsconfigCache.set(d, null);
1425
+ return null;
1426
+ }
1427
+ function pathAlias(cfg) {
1428
+ const m = /["']([^"']+)\/\*["']\s*:\s*\[\s*["']([^"']+)\/\*["']/.exec(cfg.text);
1429
+ if (!m) return null;
1430
+ const baseUrl = /["']baseUrl["']\s*:\s*["']([^"']+)["']/.exec(cfg.text)?.[1] ?? ".";
1431
+ return { prefix: m[1], root: resolve(cfg.dir, baseUrl, m[2]) };
1432
+ }
1433
+ function specifiers(content) {
1434
+ const lines = content.split("\n");
1435
+ const out = [];
1436
+ for (const m of content.matchAll(SPECIFIER)) {
1437
+ const spec = m[1] ?? m[2] ?? m[3] ?? m[4];
1438
+ if (!spec) continue;
1439
+ const line = content.slice(0, m.index).split("\n").length;
1440
+ const text = lines[line - 1] ?? "";
1441
+ if (COMMENT_LINE.test(text) || text.includes("enigma:")) continue;
1442
+ out.push({ spec, line });
1443
+ }
1444
+ return out;
1445
+ }
1446
+ function extensionImports(content, file) {
1447
+ const cfg = nearestTsconfig(file);
1448
+ if (!cfg || !/["']module(?:Resolution)?["']\s*:\s*["'](?:bundler|preserve)["']/i.test(cfg.text)) return [];
1449
+ const dir = dirname(resolve(file));
1450
+ const out = [];
1451
+ for (const { spec, line } of specifiers(content)) {
1452
+ if (!/^\.\.?\//.test(spec) || !MODULE_EXT.test(spec)) continue;
1453
+ if (JS_EXT.test(spec) && existsSync(resolve(dir, spec))) continue;
1454
+ out.push({ line, detail: `"${spec}" -> "${spec.replace(MODULE_EXT, "")}"` });
1455
+ }
1456
+ return out;
1457
+ }
1458
+ function deepRelativeImports(content, file) {
1459
+ const cfg = nearestTsconfig(file);
1460
+ const alias = cfg && pathAlias(cfg);
1461
+ if (!alias) return [];
1462
+ const dir = dirname(resolve(file));
1463
+ const out = [];
1464
+ for (const { spec, line } of specifiers(content)) {
1465
+ if (!/^(?:\.\.\/){2,}/.test(spec)) continue;
1466
+ const target = resolve(dir, spec);
1467
+ const rel = target.slice(alias.root.length + 1).replace(/\\/g, "/");
1468
+ if (!target.startsWith(`${alias.root}${sep}`) || !rel) continue;
1469
+ out.push({ line, detail: `"${spec}" -> "${alias.prefix}/${rel}"` });
1470
+ }
1471
+ return out;
1472
+ }
1473
+ function missingPathAlias(content, file) {
1474
+ if (/["'](?:paths|extends)["']\s*:/.test(content)) return [];
1475
+ const dir = dirname(resolve(file));
1476
+ const src = ["src", "app", "lib"].find((d) => existsSync(join(dir, d)));
1477
+ if (!src) return [];
1478
+ const anchor = content.split("\n").findIndex((l) => /["']compilerOptions["']/.test(l));
1479
+ return [{ line: anchor === -1 ? 1 : anchor + 1, detail: `no alias for ./${src}` }];
1480
+ }
1085
1481
  var NAMED_IMPORT = /^import[ \t]+(?:[\w$]+[ \t]*,[ \t]*)?(?:type[ \t]+)?\{([^}]*)\}[ \t]*from[ \t]*["']([^"']+)["'].*$/gm;
1086
1482
  var INTERNAL_MODULE = /^\.|^#|^[@~]\//;
1087
1483
  function wideNamedImports(content, max) {
@@ -1109,6 +1505,16 @@ function globToRegExp(glob, ignoreCase = false) {
1109
1505
  const body = esc.replace(/\*\*/g, " ").replace(/\*/g, "[^/]*").replace(/ /g, ".*").replace(/\?/g, "[^/]");
1110
1506
  return new RegExp(glob.includes("/") ? `^${body}$` : `(^|/)${body}$`, ignoreCase ? "i" : "");
1111
1507
  }
1508
+ var globCache = /* @__PURE__ */ new Map();
1509
+ function globRe(glob, ignoreCase = false) {
1510
+ const key = `${ignoreCase ? "i" : "s"}:${glob}`;
1511
+ let re = globCache.get(key);
1512
+ if (!re) {
1513
+ re = globToRegExp(glob, ignoreCase);
1514
+ globCache.set(key, re);
1515
+ }
1516
+ return re;
1517
+ }
1112
1518
  function guardrailsConfigPath() {
1113
1519
  return process.env.ENIGMA_GUARDRAILS_CONFIG || join(homedir(), ".enigma-guardrails.json");
1114
1520
  }
@@ -1148,12 +1554,13 @@ function findProjectRoot(file) {
1148
1554
  }
1149
1555
  return null;
1150
1556
  }
1151
- function checkFile(file, content, projectRoot) {
1557
+ function checkFile(file, content, projectRoot, stage = "edit", rules = loadRules()) {
1152
1558
  const norm = file.replace(/\\/g, "/");
1153
1559
  const out = [];
1154
- for (const rule of loadRules()) {
1155
- if (!rule.files.some((g) => globToRegExp(g, rule.ignoreFileCase).test(norm))) continue;
1156
- if (rule.excludeFiles?.some((g) => globToRegExp(g, rule.ignoreFileCase).test(norm))) continue;
1560
+ for (const rule of rules) {
1561
+ if (stage === "edit" && rule.stage === "diff") continue;
1562
+ if (!rule.files.some((g) => globRe(g, rule.ignoreFileCase).test(norm))) continue;
1563
+ if (rule.excludeFiles?.some((g) => globRe(g, rule.ignoreFileCase).test(norm))) continue;
1157
1564
  const base = { ruleId: rule.id, severity: rule.severity, file: norm, message: rule.message, skill: rule.skill };
1158
1565
  if (rule.scope === "file" && rule.maxBytes) {
1159
1566
  const bytes = Buffer.byteLength(content, "utf8");
@@ -1164,7 +1571,7 @@ function checkFile(file, content, projectRoot) {
1164
1571
  }
1165
1572
  } else if (rule.scope === "file" && rule.fileCheck) {
1166
1573
  const check = FILE_CHECKS[rule.fileCheck];
1167
- for (const hit of check ? check(content) : []) {
1574
+ for (const hit of check ? check(content, file) : []) {
1168
1575
  out.push({ ...base, line: hit.line, message: `${rule.message} (${hit.detail})` });
1169
1576
  }
1170
1577
  } else if (rule.scope === "file" && rule.pattern) {
@@ -1200,7 +1607,7 @@ function formatFindings(findings) {
1200
1607
  return `${tag} ${f.file}${loc} (${f.ruleId})${skill}: ${f.message}`;
1201
1608
  }).join("\n");
1202
1609
  }
1203
- function checkPath(file) {
1610
+ function checkPath(file, stage = "edit") {
1204
1611
  let content;
1205
1612
  try {
1206
1613
  content = readFileSync(file, "utf8");
@@ -1208,7 +1615,7 @@ function checkPath(file) {
1208
1615
  return [];
1209
1616
  }
1210
1617
  if (content.includes("\0")) return [];
1211
- return checkFile(file, content, findProjectRoot(file));
1618
+ return checkFile(file, content, findProjectRoot(file), stage);
1212
1619
  }
1213
1620
  function runGuardrailsHook(payload) {
1214
1621
  let file;
@@ -1223,9 +1630,12 @@ function runGuardrailsHook(payload) {
1223
1630
  if (fixed.length) process.stdout.write(`enigma guardrails (fixed)
1224
1631
  ${fixed.map((f) => `${f.file}:${f.line} (${f.ruleId})`).join("\n")}
1225
1632
  `);
1633
+ recordFindings(fixed, "fixed");
1226
1634
  if (!findings.length) return 0;
1227
1635
  const warns = findings.filter((f) => f.severity === "warn");
1228
1636
  const blocks = findings.filter((f) => f.severity === "block");
1637
+ recordFindings(warns, "warned");
1638
+ recordFindings(blocks, "blocked");
1229
1639
  if (warns.length) process.stdout.write(`enigma guardrails (suggestions)
1230
1640
  ${formatFindings(warns)}
1231
1641
  `);
@@ -1238,6 +1648,97 @@ Fix the above before continuing.
1238
1648
  }
1239
1649
  return 0;
1240
1650
  }
1651
+ var LEDGER_MAX_BYTES = 512 * 1024;
1652
+ var LEDGER_KEEP = 2e3;
1653
+ function ledgerPath() {
1654
+ return process.env.ENIGMA_GUARDRAILS_LOG || join(homedir(), ".enigma", "guardrail-log.jsonl");
1655
+ }
1656
+ function ledgerKey(rule, outcome, file, line) {
1657
+ return JSON.stringify([rule, outcome, file, line ?? null]);
1658
+ }
1659
+ function recordedToday(day) {
1660
+ const seen = /* @__PURE__ */ new Set();
1661
+ eachLedgerEntry(1, (e) => {
1662
+ if (e.at.slice(0, 10) === day) seen.add(ledgerKey(e.rule, e.outcome, e.file, e.line));
1663
+ });
1664
+ return seen;
1665
+ }
1666
+ function recordFindings(findings, outcome, stage = "edit") {
1667
+ if (!findings.length) return;
1668
+ const at = (/* @__PURE__ */ new Date()).toISOString();
1669
+ const seen = stage === "diff" || outcome !== "blocked" ? recordedToday(at.slice(0, 10)) : null;
1670
+ const rows = [];
1671
+ for (const f of findings) {
1672
+ if (seen) {
1673
+ const key = ledgerKey(f.ruleId, outcome, f.file, f.line);
1674
+ if (seen.has(key)) continue;
1675
+ seen.add(key);
1676
+ }
1677
+ rows.push(JSON.stringify({ at, rule: f.ruleId, severity: f.severity, outcome, stage, file: f.file, line: f.line }));
1678
+ }
1679
+ if (!rows.length) return;
1680
+ const path = ledgerPath();
1681
+ try {
1682
+ mkdirSync(dirname(path), { recursive: true });
1683
+ let size = 0;
1684
+ try {
1685
+ size = statSync(path).size;
1686
+ } catch {
1687
+ }
1688
+ if (size > LEDGER_MAX_BYTES) {
1689
+ const kept = readFileSync(path, "utf8").split("\n").filter(Boolean).slice(-LEDGER_KEEP);
1690
+ writeFileSync(path, `${kept.join("\n")}
1691
+ `);
1692
+ }
1693
+ appendFileSync(path, `${rows.join("\n")}
1694
+ `);
1695
+ } catch {
1696
+ }
1697
+ }
1698
+ function eachLedgerEntry(sinceDays, visit) {
1699
+ let text;
1700
+ try {
1701
+ text = readFileSync(ledgerPath(), "utf8");
1702
+ } catch {
1703
+ return;
1704
+ }
1705
+ const cutoff = sinceDays > 0 ? Date.now() - sinceDays * 864e5 : 0;
1706
+ for (const line of text.split("\n")) {
1707
+ if (!line.trim()) continue;
1708
+ try {
1709
+ const entry = JSON.parse(line);
1710
+ if (typeof entry?.rule !== "string") continue;
1711
+ if (cutoff && Date.parse(entry.at) < cutoff) continue;
1712
+ visit(entry);
1713
+ } catch {
1714
+ }
1715
+ }
1716
+ }
1717
+ function readLedger(sinceDays = 0) {
1718
+ const out = [];
1719
+ eachLedgerEntry(sinceDays, (entry) => out.push(entry));
1720
+ return out;
1721
+ }
1722
+ function countLedger(sinceDays = 0) {
1723
+ let count = 0;
1724
+ eachLedgerEntry(sinceDays, () => {
1725
+ count++;
1726
+ });
1727
+ return count;
1728
+ }
1729
+ function summarizeLedger(entries) {
1730
+ const by = /* @__PURE__ */ new Map();
1731
+ for (const e of entries) {
1732
+ const row = by.get(e.rule) || { rule: e.rule, total: 0, blocked: 0, warned: 0, fixed: 0, last: e.at };
1733
+ row.total++;
1734
+ if (e.outcome === "blocked") row.blocked++;
1735
+ else if (e.outcome === "warned") row.warned++;
1736
+ else row.fixed++;
1737
+ if (e.at > row.last) row.last = e.at;
1738
+ by.set(e.rule, row);
1739
+ }
1740
+ return [...by.values()].sort((a, b) => b.total - a.total || a.rule.localeCompare(b.rule));
1741
+ }
1241
1742
  function gitFiles(all) {
1242
1743
  const out = execFileSync("git", all ? ["ls-files"] : ["diff", "--cached", "--name-only", "--diff-filter=ACM"], { encoding: "utf8", windowsHide: true });
1243
1744
  return out.split("\n").map((s) => s.trim()).filter(Boolean);
@@ -1297,12 +1798,21 @@ export {
1297
1798
  applyFixes,
1298
1799
  checkFile,
1299
1800
  checkPath,
1801
+ countLedger,
1802
+ deepRelativeImports,
1803
+ extensionImports,
1300
1804
  findProjectRoot,
1301
1805
  formatFindings,
1302
1806
  loadRules,
1807
+ missingPathAlias,
1303
1808
  missingWindowsHide,
1809
+ readLedger,
1810
+ recordFindings,
1304
1811
  runGuardrailsHook,
1305
1812
  runGuardrailsScan,
1306
1813
  runGuardrailsScanCli,
1814
+ serverFirstMutation,
1815
+ summarizeLedger,
1816
+ textareaSizeBounds,
1307
1817
  wideNamedImports
1308
1818
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enigma-cli",
3
- "version": "1.33.6",
3
+ "version": "1.34.0",
4
4
  "description": "Everything you need to work with a coding agent: install shared policy skills for Claude Code, OpenAI Codex and opencode, and set up portable git security hooks.",
5
5
  "type": "module",
6
6
  "bin": {