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.
- package/assets/memory/AGENTS.md +2 -0
- package/assets/memory/CLAUDE.md +2 -0
- package/assets/skills/anti-overengineering-policy/skill.json +1 -1
- package/assets/skills/anti-overengineering-review/skill.json +1 -1
- package/assets/skills/backend-policy/SKILL.md +39 -2
- package/assets/skills/backend-policy/skill.json +5 -5
- package/assets/skills/ciphera-style-policy/SKILL.md +26 -1
- package/assets/skills/ciphera-style-policy/skill.json +5 -5
- package/assets/skills/code-review-policy/SKILL.md +2 -0
- package/assets/skills/code-review-policy/skill.json +4 -4
- package/assets/skills/core-engineering-policy/SKILL.md +19 -1
- package/assets/skills/core-engineering-policy/skill.json +5 -5
- package/assets/skills/database-expert/SKILL.md +19 -1
- package/assets/skills/database-expert/skill.json +4 -4
- package/assets/skills/debugging-policy/SKILL.md +2 -1
- package/assets/skills/debugging-policy/skill.json +4 -4
- package/assets/skills/dependency-policy/skill.json +1 -1
- package/assets/skills/email-policy/skill.json +1 -1
- package/assets/skills/frontend-design/skill.json +1 -1
- package/assets/skills/frontend-policy/SKILL.md +35 -5
- package/assets/skills/frontend-policy/skill.json +4 -4
- package/assets/skills/git-policy/skill.json +1 -1
- package/assets/skills/logo-sourcing-policy/skill.json +1 -1
- package/assets/skills/security-policy/SKILL.md +1 -0
- package/assets/skills/security-policy/skill.json +4 -4
- package/assets/skills/skill-creator/assets/eval_review.html +3 -1
- package/assets/skills/skill-creator/eval-viewer/viewer.html +1 -0
- package/assets/skills/skill-creator/skill.json +3 -3
- package/assets/skills/task-completion-policy/SKILL.md +1 -0
- package/assets/skills/task-completion-policy/skill.json +4 -4
- package/assets/skills/technical-writing-policy/skill.json +1 -1
- package/assets/skills/testing-policy/skill.json +1 -1
- package/assets/skills/validation-policy/skill.json +1 -1
- package/bin/checksums.json +4 -4
- package/dist/guardrails.js +522 -12
- package/package.json +1 -1
package/dist/guardrails.js
CHANGED
|
@@ -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
|
|
423
|
-
|
|
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
|
|
1155
|
-
if (
|
|
1156
|
-
if (rule.
|
|
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.
|
|
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": {
|