@savvy-web/silk-effects 0.6.0 → 1.0.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/README.md +48 -17
- package/_virtual/_rolldown/runtime.js +18 -0
- package/changesets/api/categories.js +247 -0
- package/changesets/api/changelog.js +134 -0
- package/changesets/api/dependency-table.js +163 -0
- package/changesets/api/linter.js +168 -0
- package/changesets/api/transformer.js +140 -0
- package/changesets/categories/index.js +299 -0
- package/changesets/categories/types.js +66 -0
- package/changesets/changelog/formatting.js +119 -0
- package/changesets/changelog/getDependencyReleaseLine.js +114 -0
- package/changesets/changelog/getReleaseLine.js +122 -0
- package/changesets/changelog/index.js +99 -0
- package/changesets/constants.js +43 -0
- package/changesets/errors.js +305 -0
- package/changesets/index.js +146 -0
- package/changesets/markdownlint/index.js +29 -0
- package/changesets/markdownlint/rules/content-structure.js +98 -0
- package/changesets/markdownlint/rules/dependency-table-format.js +170 -0
- package/changesets/markdownlint/rules/heading-hierarchy.js +61 -0
- package/changesets/markdownlint/rules/required-sections.js +54 -0
- package/changesets/markdownlint/rules/uncategorized-content.js +54 -0
- package/changesets/markdownlint/rules/utils.js +30 -0
- package/changesets/remark/plugins/aggregate-dependency-tables.js +47 -0
- package/changesets/remark/plugins/contributor-footnotes.js +123 -0
- package/changesets/remark/plugins/deduplicate-items.js +30 -0
- package/changesets/remark/plugins/issue-link-refs.js +58 -0
- package/changesets/remark/plugins/merge-sections.js +43 -0
- package/changesets/remark/plugins/normalize-format.js +47 -0
- package/changesets/remark/plugins/reorder-sections.js +34 -0
- package/changesets/remark/presets.js +119 -0
- package/changesets/remark/rules/content-structure.js +22 -0
- package/changesets/remark/rules/dependency-table-format.js +40 -0
- package/changesets/remark/rules/heading-hierarchy.js +19 -0
- package/changesets/remark/rules/required-sections.js +17 -0
- package/changesets/remark/rules/uncategorized-content.js +31 -0
- package/changesets/schemas/changeset.js +146 -0
- package/changesets/schemas/dependency-table.js +189 -0
- package/changesets/schemas/git.js +69 -0
- package/changesets/schemas/github.js +175 -0
- package/changesets/schemas/options.js +182 -0
- package/changesets/schemas/package-scope.js +128 -0
- package/changesets/schemas/primitives.js +72 -0
- package/changesets/schemas/version-files.js +151 -0
- package/changesets/services/branch-analyzer.js +278 -0
- package/changesets/services/changelog.js +50 -0
- package/changesets/services/config-inspector.js +390 -0
- package/changesets/services/github.js +178 -0
- package/changesets/services/markdown.js +106 -0
- package/changesets/services/workspace-snapshot.js +182 -0
- package/changesets/utils/commit-parser.js +80 -0
- package/changesets/utils/dep-diff.js +77 -0
- package/changesets/utils/dependency-table.js +347 -0
- package/changesets/utils/issue-refs.js +101 -0
- package/changesets/utils/jsonpath.js +175 -0
- package/changesets/utils/logger.js +50 -0
- package/changesets/utils/markdown-link.js +57 -0
- package/changesets/utils/publishability.js +39 -0
- package/changesets/utils/remark-pipeline.js +79 -0
- package/changesets/utils/section-parser.js +94 -0
- package/changesets/utils/strip-frontmatter.js +46 -0
- package/changesets/utils/version-blocks.js +108 -0
- package/changesets/utils/version-files.js +336 -0
- package/changesets/utils/worktree-snapshot.js +142 -0
- package/changesets/vendor/github-info.js +55 -0
- package/commitlint/config/factory.js +69 -0
- package/commitlint/config/plugins.js +227 -0
- package/commitlint/config/rules.js +155 -0
- package/commitlint/config/schema.js +46 -0
- package/commitlint/detection/dco.js +53 -0
- package/commitlint/detection/scopes.js +45 -0
- package/commitlint/formatter/format.js +85 -0
- package/commitlint/formatter/messages.js +79 -0
- package/commitlint/hook/diagnostics/branch.js +36 -0
- package/commitlint/hook/diagnostics/cache.js +37 -0
- package/commitlint/hook/diagnostics/commitlint-config.js +36 -0
- package/commitlint/hook/diagnostics/open-issues.js +56 -0
- package/commitlint/hook/diagnostics/package-manager.js +51 -0
- package/commitlint/hook/diagnostics/signing.js +107 -0
- package/commitlint/hook/envelope.js +46 -0
- package/commitlint/hook/output.js +45 -0
- package/commitlint/hook/parse-bash-command.js +105 -0
- package/commitlint/hook/rules/closes-trailer.js +31 -0
- package/commitlint/hook/rules/forbidden-content.js +32 -0
- package/commitlint/hook/rules/plan-leakage.js +36 -0
- package/commitlint/hook/rules/signing-flag-conflict.js +25 -0
- package/commitlint/hook/rules/soft-wrap.js +37 -0
- package/commitlint/hook/rules/types.js +14 -0
- package/commitlint/hook/rules/verbosity.js +31 -0
- package/commitlint/hook/silence-logger.js +39 -0
- package/commitlint/index.js +146 -0
- package/commitlint/prompt/config.js +91 -0
- package/commitlint/prompt/emojis.js +74 -0
- package/commitlint/prompt/prompter.js +135 -0
- package/commitlint/static.js +73 -0
- package/errors/BiomeSyncError.js +21 -0
- package/errors/ChangesetConfigError.js +20 -0
- package/errors/ConfigNotFoundError.js +21 -0
- package/errors/SectionParseError.js +16 -0
- package/errors/SectionValidationError.js +16 -0
- package/errors/SectionWriteError.js +16 -0
- package/errors/TagFormatError.js +20 -0
- package/errors/ToolNotFoundError.js +11 -0
- package/errors/ToolResolutionError.js +11 -0
- package/errors/ToolVersionMismatchError.js +11 -0
- package/errors/VersioningDetectionError.js +20 -0
- package/errors/WorkspaceAnalysisError.js +21 -0
- package/index.d.ts +9743 -8380
- package/index.js +36 -6657
- package/lint/Handler.js +39 -0
- package/lint/cli/sections.js +65 -0
- package/lint/cli/templates/markdownlint.gen.js +183 -0
- package/lint/config/Preset.js +152 -0
- package/lint/config/createConfig.js +89 -0
- package/lint/handlers/Biome.js +179 -0
- package/lint/handlers/Markdown.js +139 -0
- package/lint/handlers/PackageJson.js +130 -0
- package/lint/handlers/PnpmWorkspace.js +141 -0
- package/lint/handlers/ShellScripts.js +58 -0
- package/lint/handlers/TypeScript.js +134 -0
- package/lint/handlers/Yaml.js +167 -0
- package/lint/index.js +52 -0
- package/lint/utils/Command.js +285 -0
- package/lint/utils/Filter.js +100 -0
- package/lint/utils/Workspace.js +86 -0
- package/package.json +52 -63
- package/schemas/CommentStyle.js +16 -0
- package/schemas/ResolvedTool.js +63 -0
- package/schemas/SavvySections.js +113 -0
- package/schemas/SectionBlock.js +70 -0
- package/schemas/SectionDefinition.js +121 -0
- package/schemas/SectionResults.js +12 -0
- package/schemas/TagStrategySchemas.js +18 -0
- package/schemas/ToolDefinition.js +39 -0
- package/schemas/ToolResults.js +14 -0
- package/schemas/VersioningSchemas.js +95 -0
- package/schemas/WorkspaceAnalysisSchemas.js +190 -0
- package/services/BiomeSchemaSync.js +133 -0
- package/services/ChangesetConfig.js +78 -0
- package/services/ChangesetConfigReader.js +106 -0
- package/services/ConfigDiscovery.js +71 -0
- package/services/ManagedSection.js +288 -0
- package/services/SilkPublishability.js +193 -0
- package/services/SilkWorkspaceAnalyzer.js +213 -0
- package/services/TagStrategy.js +54 -0
- package/services/ToolDiscovery.js +229 -0
- package/services/VersioningStrategy.js +67 -0
- package/tsdoc-metadata.json +11 -11
- package/turbo/digest.js +127 -0
- package/turbo/errors.js +48 -0
- package/turbo/index.js +32 -0
- package/turbo/schemas/DryRun.js +57 -0
- package/turbo/schemas/results.js +61 -0
- package/turbo/services/TurboInspector.js +100 -0
- package/utils/ToolCommand.js +40 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/commitlint/formatter/messages.ts
|
|
2
|
+
/**
|
|
3
|
+
* Error message templates for the formatter.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Detailed error explanations for common rule failures.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Maps commitlint rule names to human-readable explanations of what
|
|
12
|
+
* the rule enforces and why the commit message failed.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const ERROR_EXPLANATIONS = {
|
|
17
|
+
"type-empty": "Commit messages must start with a type (e.g., feat, fix, docs).",
|
|
18
|
+
"type-enum": "The commit type must be one of: ai, build, chore, ci, docs, feat, fix, perf, refactor, release, revert, style, test.",
|
|
19
|
+
"subject-empty": "A subject describing the change is required after the type.",
|
|
20
|
+
"subject-case": "The subject should start with a lowercase letter.",
|
|
21
|
+
"subject-full-stop": "The subject should not end with a period.",
|
|
22
|
+
"header-max-length": "The first line of the commit message is too long. Keep it under 72 characters.",
|
|
23
|
+
"body-max-line-length": "Lines in the commit body should not exceed the configured maximum length.",
|
|
24
|
+
"body-leading-blank": "There should be a blank line between the subject and the body.",
|
|
25
|
+
"footer-leading-blank": "There should be a blank line between the body and the footer.",
|
|
26
|
+
"scope-enum": "The scope must be one of the allowed values for this project.",
|
|
27
|
+
"scope-empty": "A scope is required for this commit type.",
|
|
28
|
+
"signed-off-by": "This project requires DCO signoff. Add \"Signed-off-by: Your Name <email>\" to your commit, or use `git commit -s`.",
|
|
29
|
+
"silk/signed-off-by": "This project requires DCO signoff. Add \"Signed-off-by: Your Name <email>\" to your commit, or use `git commit -s`.",
|
|
30
|
+
"trailer-exists": "A required trailer is missing from the commit message.",
|
|
31
|
+
"references-empty": "This commit should reference an issue (e.g., #123 or PROJ-456)."
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Suggestions for fixing common errors.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Maps commitlint rule names to actionable suggestions for fixing
|
|
38
|
+
* the rule violation.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
const ERROR_SUGGESTIONS = {
|
|
43
|
+
"type-empty": "Example: feat: add user authentication",
|
|
44
|
+
"type-enum": "Example: fix: resolve memory leak in cache",
|
|
45
|
+
"subject-empty": "Example: feat: add user authentication",
|
|
46
|
+
"subject-case": "Use lowercase: \"add feature\" instead of \"Add feature\"",
|
|
47
|
+
"subject-full-stop": "Remove the period: \"add feature\" instead of \"add feature.\"",
|
|
48
|
+
"header-max-length": "Move details to the commit body instead.",
|
|
49
|
+
"body-max-line-length": "Break long lines into multiple shorter lines.",
|
|
50
|
+
"body-leading-blank": "Add an empty line after the subject before the body.",
|
|
51
|
+
"footer-leading-blank": "Add an empty line before any footers like Signed-off-by.",
|
|
52
|
+
"signed-off-by": "Run: git commit --amend -s",
|
|
53
|
+
"silk/signed-off-by": "Run: git commit --amend -s"
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Get explanation for a rule failure.
|
|
57
|
+
*
|
|
58
|
+
* @param ruleName - Name of the failed rule
|
|
59
|
+
* @returns Explanation string or undefined if no explanation exists
|
|
60
|
+
*
|
|
61
|
+
* @public
|
|
62
|
+
*/
|
|
63
|
+
function getExplanation(ruleName) {
|
|
64
|
+
return ERROR_EXPLANATIONS[ruleName];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get suggestion for fixing a rule failure.
|
|
68
|
+
*
|
|
69
|
+
* @param ruleName - Name of the failed rule
|
|
70
|
+
* @returns Suggestion string or undefined if no suggestion exists
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
function getSuggestion(ruleName) {
|
|
75
|
+
return ERROR_SUGGESTIONS[ruleName];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { ERROR_EXPLANATIONS, ERROR_SUGGESTIONS, getExplanation, getSuggestion };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
//#region src/commitlint/hook/diagnostics/branch.ts
|
|
6
|
+
/**
|
|
7
|
+
* Branch and inferred-ticket detection.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
const execFileP = promisify(execFile);
|
|
12
|
+
const TICKET_RE = /^[a-z]+\/(\d+)[-/_]/;
|
|
13
|
+
function inferTicketId(branch) {
|
|
14
|
+
const m = branch.match(TICKET_RE);
|
|
15
|
+
return m ? Number(m[1]) : null;
|
|
16
|
+
}
|
|
17
|
+
function readBranchInfo() {
|
|
18
|
+
return Effect.tryPromise(async () => {
|
|
19
|
+
const { stdout } = await execFileP("git", [
|
|
20
|
+
"rev-parse",
|
|
21
|
+
"--abbrev-ref",
|
|
22
|
+
"HEAD"
|
|
23
|
+
]);
|
|
24
|
+
const branch = stdout.trim();
|
|
25
|
+
return {
|
|
26
|
+
branch,
|
|
27
|
+
inferredTicketId: inferTicketId(branch)
|
|
28
|
+
};
|
|
29
|
+
}).pipe(Effect.orElseSucceed(() => ({
|
|
30
|
+
branch: null,
|
|
31
|
+
inferredTicketId: null
|
|
32
|
+
})));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
export { inferTicketId, readBranchInfo };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/commitlint/hook/diagnostics/cache.ts
|
|
6
|
+
/**
|
|
7
|
+
* JSON file cache with TTL. Writes are atomic-ish (mkdir -p, write,
|
|
8
|
+
* rename). Reads return null on any error or staleness — callers
|
|
9
|
+
* recompute silently.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
function readCache(path, ttlSeconds) {
|
|
14
|
+
return Effect.tryPromise(async () => {
|
|
15
|
+
const raw = await readFile(path, "utf8");
|
|
16
|
+
const env = JSON.parse(raw);
|
|
17
|
+
const cachedMs = Date.parse(env.cachedAt);
|
|
18
|
+
if (Number.isNaN(cachedMs)) return null;
|
|
19
|
+
if (Date.now() - cachedMs > ttlSeconds * 1e3) return null;
|
|
20
|
+
return env.data;
|
|
21
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
22
|
+
}
|
|
23
|
+
function writeCache(path, data, when = /* @__PURE__ */ new Date()) {
|
|
24
|
+
return Effect.tryPromise(async () => {
|
|
25
|
+
await mkdir(dirname(path), { recursive: true });
|
|
26
|
+
const env = {
|
|
27
|
+
cachedAt: when.toISOString(),
|
|
28
|
+
data
|
|
29
|
+
};
|
|
30
|
+
const tmp = `${path}.tmp`;
|
|
31
|
+
await writeFile(tmp, JSON.stringify(env), "utf8");
|
|
32
|
+
await rename(tmp, path);
|
|
33
|
+
}).pipe(Effect.orElseSucceed(() => void 0));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//#endregion
|
|
37
|
+
export { readCache, writeCache };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
//#region src/commitlint/hook/diagnostics/commitlint-config.ts
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the commitlint config path used by the husky commit-msg hook.
|
|
7
|
+
*
|
|
8
|
+
* The husky hook is the source of truth: `init` writes the chosen path into
|
|
9
|
+
* its managed section as `--config "$ROOT/<path>"`. The post-commit verifier
|
|
10
|
+
* extracts that same path so its replay matches the committed-time invocation.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const CONFIG_FLAG_RE = /--config\s+(?:"([^"]+)"|'([^']+)'|(\S+))/;
|
|
15
|
+
const ROOT_PREFIXES = ["$ROOT/", "${ROOT}/"];
|
|
16
|
+
function parseHuskyConfigPath(huskyContent, root) {
|
|
17
|
+
const m = huskyContent.match(CONFIG_FLAG_RE);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
let raw = m[1] ?? m[2] ?? m[3] ?? "";
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
for (const prefix of ROOT_PREFIXES) if (raw.startsWith(prefix)) {
|
|
22
|
+
raw = raw.slice(prefix.length);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
return isAbsolute(raw) ? raw : resolve(root, raw);
|
|
26
|
+
}
|
|
27
|
+
async function readCommitlintConfigPath(root) {
|
|
28
|
+
try {
|
|
29
|
+
return parseHuskyConfigPath(await readFile(join(root, ".husky/commit-msg"), "utf8"), root);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
export { parseHuskyConfigPath, readCommitlintConfigPath };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readCache, writeCache } from "./cache.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
//#region src/commitlint/hook/diagnostics/open-issues.ts
|
|
7
|
+
/**
|
|
8
|
+
* Open-issue lookup via gh CLI, cached on disk.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const execFileP = promisify(execFile);
|
|
13
|
+
const ISSUES_CACHE_TTL_SECONDS = 600;
|
|
14
|
+
/** Relative path under CLAUDE_PROJECT_DIR where the open-issues cache lives. */
|
|
15
|
+
const ISSUES_CACHE_RELATIVE_PATH = ".claude/cache/issues.json";
|
|
16
|
+
function readOpenIssuesFromCache(cachePath, ttlSeconds = 600) {
|
|
17
|
+
return readCache(cachePath, ttlSeconds);
|
|
18
|
+
}
|
|
19
|
+
function fetchAndCacheOpenIssues(cachePath) {
|
|
20
|
+
return Effect.gen(function* () {
|
|
21
|
+
const repo = (yield* Effect.tryPromise(() => execFileP("gh", [
|
|
22
|
+
"repo",
|
|
23
|
+
"view",
|
|
24
|
+
"--json",
|
|
25
|
+
"nameWithOwner",
|
|
26
|
+
"-q",
|
|
27
|
+
".nameWithOwner"
|
|
28
|
+
]))).stdout.trim();
|
|
29
|
+
if (!repo) return null;
|
|
30
|
+
const listResult = yield* Effect.tryPromise(() => execFileP("gh", [
|
|
31
|
+
"issue",
|
|
32
|
+
"list",
|
|
33
|
+
"--repo",
|
|
34
|
+
repo,
|
|
35
|
+
"--state",
|
|
36
|
+
"open",
|
|
37
|
+
"--limit",
|
|
38
|
+
"20",
|
|
39
|
+
"--json",
|
|
40
|
+
"number,title"
|
|
41
|
+
]));
|
|
42
|
+
const parsed = JSON.parse(listResult.stdout);
|
|
43
|
+
yield* writeCache(cachePath, parsed);
|
|
44
|
+
return parsed;
|
|
45
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
46
|
+
}
|
|
47
|
+
function readOrFetchOpenIssues(cachePath) {
|
|
48
|
+
return Effect.gen(function* () {
|
|
49
|
+
const cached = yield* readOpenIssuesFromCache(cachePath);
|
|
50
|
+
if (cached !== null) return cached;
|
|
51
|
+
return yield* fetchAndCacheOpenIssues(cachePath);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
export { ISSUES_CACHE_RELATIVE_PATH, fetchAndCacheOpenIssues, readOpenIssuesFromCache, readOrFetchOpenIssues };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/commitlint/hook/diagnostics/package-manager.ts
|
|
6
|
+
/**
|
|
7
|
+
* Package manager detection for the commit-time hooks.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the husky hook detection: prefer `package.json#packageManager`,
|
|
10
|
+
* fall back to lockfile presence in priority order pnpm \> yarn \> bun \> npm.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const VALID_PMS = new Set([
|
|
15
|
+
"pnpm",
|
|
16
|
+
"yarn",
|
|
17
|
+
"bun",
|
|
18
|
+
"npm"
|
|
19
|
+
]);
|
|
20
|
+
function parsePackageManagerField(packageJsonContent) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(packageJsonContent);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const field = typeof parsed === "object" && parsed !== null && "packageManager" in parsed ? parsed.packageManager : void 0;
|
|
28
|
+
if (typeof field !== "string" || field.length === 0) return null;
|
|
29
|
+
const name = field.split("@")[0];
|
|
30
|
+
return VALID_PMS.has(name) ? name : null;
|
|
31
|
+
}
|
|
32
|
+
function detectFromLockfiles(presence) {
|
|
33
|
+
if (presence.pnpm) return "pnpm";
|
|
34
|
+
if (presence.yarn) return "yarn";
|
|
35
|
+
if (presence.bun) return "bun";
|
|
36
|
+
return "npm";
|
|
37
|
+
}
|
|
38
|
+
async function detectPackageManager(root) {
|
|
39
|
+
try {
|
|
40
|
+
const fromField = parsePackageManagerField(await readFile(join(root, "package.json"), "utf8"));
|
|
41
|
+
if (fromField !== null) return fromField;
|
|
42
|
+
} catch {}
|
|
43
|
+
return detectFromLockfiles({
|
|
44
|
+
pnpm: existsSync(join(root, "pnpm-lock.yaml")),
|
|
45
|
+
yarn: existsSync(join(root, "yarn.lock")),
|
|
46
|
+
bun: existsSync(join(root, "bun.lock"))
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { detectFromLockfiles, detectPackageManager, parsePackageManagerField };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
//#region src/commitlint/hook/diagnostics/signing.ts
|
|
7
|
+
/**
|
|
8
|
+
* GPG / SSH signing diagnostic.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const execFileP = promisify(execFile);
|
|
13
|
+
function parseGpgKeyExpiry(colonsOutput) {
|
|
14
|
+
for (const line of colonsOutput.split("\n")) {
|
|
15
|
+
if (!line.startsWith("sec:")) continue;
|
|
16
|
+
const expires = line.split(":")[6];
|
|
17
|
+
if (expires && /^\d+$/.test(expires)) return (/* @__PURE__ */ new Date(Number(expires) * 1e3)).toISOString();
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function buildSigningDiagnostic(raw) {
|
|
22
|
+
const signingKeyConfigured = !!raw.signingKey;
|
|
23
|
+
const format = !signingKeyConfigured ? "none" : raw.gpgFormat === "ssh" ? "ssh" : "gpg";
|
|
24
|
+
const autoSignEnabled = raw.commitGpgsign === "true";
|
|
25
|
+
const warnings = [];
|
|
26
|
+
if (!autoSignEnabled) warnings.push("commits will be unsigned (commit.gpgsign is not true)");
|
|
27
|
+
if (!signingKeyConfigured) warnings.push("user.signingkey is not configured");
|
|
28
|
+
if (signingKeyConfigured && !raw.keyResolves) warnings.push("user.signingkey does not resolve to an existing key/file");
|
|
29
|
+
if (raw.keyExpiry && Date.parse(raw.keyExpiry) < Date.now()) warnings.push(`signing key has expired (expired ${raw.keyExpiry})`);
|
|
30
|
+
if (format === "gpg" && !raw.agentResponsive) warnings.push("gpg-agent did not respond");
|
|
31
|
+
if (format === "ssh" && !raw.sshAllowedSignersFile) warnings.push("gpg.ssh.allowedSignersFile is unset; signature verification will fail");
|
|
32
|
+
return {
|
|
33
|
+
format,
|
|
34
|
+
autoSignEnabled,
|
|
35
|
+
signingKeyConfigured,
|
|
36
|
+
keyResolves: raw.keyResolves,
|
|
37
|
+
agentResponsive: raw.agentResponsive,
|
|
38
|
+
warnings
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function gitConfig(key) {
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await execFileP("git", [
|
|
44
|
+
"config",
|
|
45
|
+
"--get",
|
|
46
|
+
key
|
|
47
|
+
]);
|
|
48
|
+
const v = stdout.trim();
|
|
49
|
+
return v.length > 0 ? v : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const FALLBACK_DIAGNOSTIC = {
|
|
55
|
+
format: "none",
|
|
56
|
+
autoSignEnabled: false,
|
|
57
|
+
signingKeyConfigured: false,
|
|
58
|
+
keyResolves: false,
|
|
59
|
+
agentResponsive: false,
|
|
60
|
+
warnings: ["signing diagnostic unavailable"]
|
|
61
|
+
};
|
|
62
|
+
function readSigningDiagnostic() {
|
|
63
|
+
return Effect.tryPromise(async () => {
|
|
64
|
+
const gpgFormat = await gitConfig("gpg.format");
|
|
65
|
+
const commitGpgsign = await gitConfig("commit.gpgsign");
|
|
66
|
+
const signingKey = await gitConfig("user.signingkey");
|
|
67
|
+
const sshAllowedSignersFile = await gitConfig("gpg.ssh.allowedSignersFile");
|
|
68
|
+
const isSsh = gpgFormat === "ssh";
|
|
69
|
+
let keyResolves = false;
|
|
70
|
+
let keyExpiry = null;
|
|
71
|
+
if (signingKey) if (isSsh) try {
|
|
72
|
+
await stat(signingKey);
|
|
73
|
+
keyResolves = true;
|
|
74
|
+
} catch {
|
|
75
|
+
keyResolves = false;
|
|
76
|
+
}
|
|
77
|
+
else try {
|
|
78
|
+
const { stdout } = await execFileP("gpg", [
|
|
79
|
+
"--list-secret-keys",
|
|
80
|
+
"--with-colons",
|
|
81
|
+
signingKey
|
|
82
|
+
]);
|
|
83
|
+
keyResolves = stdout.trim().length > 0;
|
|
84
|
+
keyExpiry = parseGpgKeyExpiry(stdout);
|
|
85
|
+
} catch {
|
|
86
|
+
keyResolves = false;
|
|
87
|
+
}
|
|
88
|
+
let agentResponsive = true;
|
|
89
|
+
if (!isSsh) try {
|
|
90
|
+
await execFileP("gpg-connect-agent", ["/bye"], { timeout: 1e3 });
|
|
91
|
+
} catch {
|
|
92
|
+
agentResponsive = false;
|
|
93
|
+
}
|
|
94
|
+
return buildSigningDiagnostic({
|
|
95
|
+
gpgFormat,
|
|
96
|
+
commitGpgsign,
|
|
97
|
+
signingKey,
|
|
98
|
+
keyResolves,
|
|
99
|
+
agentResponsive,
|
|
100
|
+
keyExpiry,
|
|
101
|
+
sshAllowedSignersFile
|
|
102
|
+
});
|
|
103
|
+
}).pipe(Effect.orElseSucceed(() => FALLBACK_DIAGNOSTIC));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//#endregion
|
|
107
|
+
export { buildSigningDiagnostic, parseGpgKeyExpiry, readSigningDiagnostic };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/commitlint/hook/envelope.ts
|
|
4
|
+
/**
|
|
5
|
+
* Schemas for the JSON stdin envelopes Claude passes to plugin hooks.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* These are intentionally permissive — `Schema.Unknown` is used for fields
|
|
9
|
+
* we don't inspect (the agent may add unknown keys; we don't want a forward-
|
|
10
|
+
* compatibility break to make the hook fail).
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const PreToolUseEnvelope = Schema.Struct({
|
|
15
|
+
hook_event_name: Schema.Literal("PreToolUse"),
|
|
16
|
+
tool_name: Schema.String,
|
|
17
|
+
tool_input: Schema.Record({
|
|
18
|
+
key: Schema.String,
|
|
19
|
+
value: Schema.Unknown
|
|
20
|
+
})
|
|
21
|
+
});
|
|
22
|
+
const PostToolUseEnvelope = Schema.Struct({
|
|
23
|
+
hook_event_name: Schema.Literal("PostToolUse"),
|
|
24
|
+
tool_name: Schema.String,
|
|
25
|
+
tool_input: Schema.Record({
|
|
26
|
+
key: Schema.String,
|
|
27
|
+
value: Schema.Unknown
|
|
28
|
+
}),
|
|
29
|
+
tool_response: Schema.Struct({
|
|
30
|
+
interrupted: Schema.optional(Schema.Boolean),
|
|
31
|
+
exit_code: Schema.optional(Schema.Number),
|
|
32
|
+
stdout: Schema.optional(Schema.String),
|
|
33
|
+
stderr: Schema.optional(Schema.String)
|
|
34
|
+
})
|
|
35
|
+
});
|
|
36
|
+
const UserPromptSubmitEnvelope = Schema.Struct({
|
|
37
|
+
hook_event_name: Schema.Literal("UserPromptSubmit"),
|
|
38
|
+
prompt: Schema.String
|
|
39
|
+
});
|
|
40
|
+
const SessionStartEnvelope = Schema.Struct({
|
|
41
|
+
hook_event_name: Schema.Literal("SessionStart"),
|
|
42
|
+
source: Schema.optional(Schema.String)
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
export { PostToolUseEnvelope, PreToolUseEnvelope, SessionStartEnvelope, UserPromptSubmitEnvelope };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/commitlint/hook/output.ts
|
|
2
|
+
function preToolUseAllow(reason) {
|
|
3
|
+
return { hookSpecificOutput: {
|
|
4
|
+
hookEventName: "PreToolUse",
|
|
5
|
+
permissionDecision: "allow",
|
|
6
|
+
permissionDecisionReason: reason
|
|
7
|
+
} };
|
|
8
|
+
}
|
|
9
|
+
function preToolUseDeny(reason) {
|
|
10
|
+
return { hookSpecificOutput: {
|
|
11
|
+
hookEventName: "PreToolUse",
|
|
12
|
+
permissionDecision: "deny",
|
|
13
|
+
permissionDecisionReason: reason
|
|
14
|
+
} };
|
|
15
|
+
}
|
|
16
|
+
function preToolUseAdvise(message) {
|
|
17
|
+
return { hookSpecificOutput: {
|
|
18
|
+
hookEventName: "PreToolUse",
|
|
19
|
+
additionalContext: message
|
|
20
|
+
} };
|
|
21
|
+
}
|
|
22
|
+
function preToolUseSilent() {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function sessionStartContext(message) {
|
|
26
|
+
return { hookSpecificOutput: {
|
|
27
|
+
hookEventName: "SessionStart",
|
|
28
|
+
additionalContext: message
|
|
29
|
+
} };
|
|
30
|
+
}
|
|
31
|
+
function postToolUseAdvise(message) {
|
|
32
|
+
return { hookSpecificOutput: {
|
|
33
|
+
hookEventName: "PostToolUse",
|
|
34
|
+
additionalContext: message
|
|
35
|
+
} };
|
|
36
|
+
}
|
|
37
|
+
function userPromptSubmitContext(message) {
|
|
38
|
+
return { hookSpecificOutput: {
|
|
39
|
+
hookEventName: "UserPromptSubmit",
|
|
40
|
+
additionalContext: message
|
|
41
|
+
} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { postToolUseAdvise, preToolUseAdvise, preToolUseAllow, preToolUseDeny, preToolUseSilent, sessionStartContext, userPromptSubmitContext };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parse } from "shell-quote";
|
|
2
|
+
|
|
3
|
+
//#region src/commitlint/hook/parse-bash-command.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parse a Bash command string to extract the commit message and flags
|
|
6
|
+
* for `git commit` / `gh pr create` / `gh pr edit` invocations.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Only enough Bash is parsed to handle the shapes Claude actually emits.
|
|
10
|
+
* Heredocs surface as already-substituted strings inside Claude's tool
|
|
11
|
+
* envelope, so we don't need full shell semantics — just argv tokenization.
|
|
12
|
+
*
|
|
13
|
+
* Operators (`&&`, `||`, `|`, `;`) are filtered out by `shell-quote`. The
|
|
14
|
+
* parser inspects only `tokens[0]` and `tokens[1]`, so a compound command
|
|
15
|
+
* like `true && git commit -m "x"` will be classified as `unknown` rather
|
|
16
|
+
* than recursed into. That is the intended behaviour for the scaffold:
|
|
17
|
+
* compound shapes are uncommon in agent-emitted commands, and silently
|
|
18
|
+
* dropping them is safer than misattributing extracted state.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
function emptyFlags() {
|
|
23
|
+
return {
|
|
24
|
+
sign: "default",
|
|
25
|
+
noVerify: false,
|
|
26
|
+
amend: false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function parseBashCommand(command) {
|
|
30
|
+
const tokens = parse(command).filter((t) => typeof t === "string");
|
|
31
|
+
if (tokens.length === 0) return {
|
|
32
|
+
kind: "unknown",
|
|
33
|
+
message: null,
|
|
34
|
+
flags: emptyFlags(),
|
|
35
|
+
source: "none"
|
|
36
|
+
};
|
|
37
|
+
if (tokens[0] === "git" && tokens[1] === "commit") {
|
|
38
|
+
const flags = extractGitCommitFlags(tokens);
|
|
39
|
+
const kind = flags.amend ? "git-commit-amend" : "git-commit";
|
|
40
|
+
const message = extractGitCommitMessage(tokens);
|
|
41
|
+
return {
|
|
42
|
+
kind,
|
|
43
|
+
message,
|
|
44
|
+
flags,
|
|
45
|
+
source: message === null ? "none" : "inline"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (tokens[0] === "gh" && tokens[1] === "pr" && (tokens[2] === "create" || tokens[2] === "edit")) {
|
|
49
|
+
const kind = tokens[2] === "create" ? "gh-pr-create" : "gh-pr-edit";
|
|
50
|
+
const message = extractFlagValue(tokens, "--body", "-b");
|
|
51
|
+
return {
|
|
52
|
+
kind,
|
|
53
|
+
message,
|
|
54
|
+
flags: emptyFlags(),
|
|
55
|
+
source: message === null ? "none" : "inline"
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
kind: "unknown",
|
|
60
|
+
message: null,
|
|
61
|
+
flags: emptyFlags(),
|
|
62
|
+
source: "none"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function extractGitCommitFlags(tokens) {
|
|
66
|
+
let sign = "default";
|
|
67
|
+
let noVerify = false;
|
|
68
|
+
let amend = false;
|
|
69
|
+
for (const tok of tokens.slice(2)) if (tok === "-S" || tok === "--gpg-sign" || tok.startsWith("--gpg-sign=")) sign = "force-on";
|
|
70
|
+
else if (tok === "--no-gpg-sign") sign = "force-off";
|
|
71
|
+
else if (tok === "--no-verify" || tok === "-n") noVerify = true;
|
|
72
|
+
else if (tok === "--amend") amend = true;
|
|
73
|
+
return {
|
|
74
|
+
sign,
|
|
75
|
+
noVerify,
|
|
76
|
+
amend
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function extractGitCommitMessage(tokens) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
for (let i = 2; i < tokens.length; i++) {
|
|
82
|
+
const tok = tokens[i];
|
|
83
|
+
if (tok === void 0) continue;
|
|
84
|
+
if (tok === "-m" || tok === "--message") {
|
|
85
|
+
const next = tokens[i + 1];
|
|
86
|
+
if (next !== void 0) {
|
|
87
|
+
parts.push(next);
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
} else if (tok.startsWith("--message=")) parts.push(tok.slice(10));
|
|
91
|
+
}
|
|
92
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
93
|
+
}
|
|
94
|
+
function extractFlagValue(tokens, ...names) {
|
|
95
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
96
|
+
const tok = tokens[i];
|
|
97
|
+
if (tok === void 0) continue;
|
|
98
|
+
if (names.includes(tok)) return tokens[i + 1] ?? null;
|
|
99
|
+
for (const name of names) if (tok.startsWith(`${name}=`)) return tok.slice(name.length + 1);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
//#endregion
|
|
105
|
+
export { parseBashCommand };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/commitlint/hook/rules/closes-trailer.ts
|
|
4
|
+
/**
|
|
5
|
+
* closes-trailer rule — advises when the branch encodes a ticket id but
|
|
6
|
+
* the message body has no Closes/Fixes/Resolves trailer for it.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
function hasClosingTrailer(message, ticketId) {
|
|
11
|
+
return new RegExp(`\\b(closes|fixes|resolves)\\s+#${ticketId}\\b`, "i").test(message);
|
|
12
|
+
}
|
|
13
|
+
const closesTrailerRule = {
|
|
14
|
+
id: "closes-trailer",
|
|
15
|
+
severity: "advise",
|
|
16
|
+
check: (input, ctx) => Effect.sync(() => {
|
|
17
|
+
const tid = ctx.branchInfo.inferredTicketId;
|
|
18
|
+
if (tid === null) return null;
|
|
19
|
+
if (hasClosingTrailer(input.message, tid)) return null;
|
|
20
|
+
const openList = ctx.openIssues.map((i) => ` #${i.number} ${i.title}`).join("\n");
|
|
21
|
+
const tail = openList.length > 0 ? `\n\nOpen issues in this repo:\n${openList}` : "";
|
|
22
|
+
return {
|
|
23
|
+
ruleId: "closes-trailer",
|
|
24
|
+
severity: "advise",
|
|
25
|
+
message: `Branch ${ctx.branchInfo.branch} looks like ticket #${tid} but the message has no Closes/Fixes/Resolves #${tid} trailer. If this commit closes #${tid}, add 'Closes #${tid}' above the Signed-off-by line.${tail}`
|
|
26
|
+
};
|
|
27
|
+
})
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { closesTrailerRule, hasClosingTrailer };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/commitlint/hook/rules/forbidden-content.ts
|
|
4
|
+
/**
|
|
5
|
+
* forbidden-content rule — denies markdown headers and code fences in
|
|
6
|
+
* commit message bodies.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const forbiddenContentRule = {
|
|
11
|
+
id: "forbidden-content",
|
|
12
|
+
severity: "deny",
|
|
13
|
+
check: (input) => Effect.sync(() => {
|
|
14
|
+
const lines = input.message.split("\n");
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (/^#{1,6}\s/.test(line)) return {
|
|
17
|
+
ruleId: "forbidden-content",
|
|
18
|
+
severity: "deny",
|
|
19
|
+
message: "Body contains a markdown header (#, ##, ...). Headers are forbidden by the @savvy-web/commitlint Silk preset. Remove the header line and rewrite as plain prose or a bullet."
|
|
20
|
+
};
|
|
21
|
+
if (/^```/.test(line)) return {
|
|
22
|
+
ruleId: "forbidden-content",
|
|
23
|
+
severity: "deny",
|
|
24
|
+
message: "Body contains a code fence (```). Code fences are forbidden in commit bodies. Move code samples to the PR description."
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
})
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { forbiddenContentRule };
|