@sha3/code 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/AGENTS.md +75 -0
- package/README.md +554 -0
- package/ai/adapters/codex.md +7 -0
- package/ai/adapters/copilot.md +7 -0
- package/ai/adapters/cursor.md +7 -0
- package/ai/adapters/windsurf.md +8 -0
- package/ai/constitution.md +12 -0
- package/bin/code-standards.mjs +47 -0
- package/biome.json +37 -0
- package/index.mjs +11 -0
- package/lib/cli/parse-args.mjs +416 -0
- package/lib/cli/post-run-guidance.mjs +43 -0
- package/lib/cli/run-init.mjs +123 -0
- package/lib/cli/run-profile.mjs +46 -0
- package/lib/cli/run-refactor.mjs +152 -0
- package/lib/cli/run-verify.mjs +67 -0
- package/lib/constants.mjs +167 -0
- package/lib/contract/load-rule-catalog.mjs +12 -0
- package/lib/contract/render-agents.mjs +79 -0
- package/lib/contract/render-contract-json.mjs +7 -0
- package/lib/contract/resolve-contract.mjs +52 -0
- package/lib/paths.mjs +50 -0
- package/lib/profile.mjs +108 -0
- package/lib/project/ai-instructions.mjs +28 -0
- package/lib/project/biome-ignore.mjs +14 -0
- package/lib/project/managed-files.mjs +105 -0
- package/lib/project/package-metadata.mjs +132 -0
- package/lib/project/prompt-files.mjs +111 -0
- package/lib/project/template-resolution.mjs +70 -0
- package/lib/refactor/materialize-refactor-context.mjs +106 -0
- package/lib/refactor/preservation-questions.mjs +33 -0
- package/lib/refactor/public-contract-extractor.mjs +22 -0
- package/lib/refactor/render-analysis-summary.mjs +50 -0
- package/lib/refactor/source-analysis.mjs +74 -0
- package/lib/utils/fs.mjs +220 -0
- package/lib/utils/prompts.mjs +63 -0
- package/lib/utils/text.mjs +43 -0
- package/lib/verify/change-audit-verifier.mjs +140 -0
- package/lib/verify/change-context.mjs +36 -0
- package/lib/verify/error-handling-verifier.mjs +164 -0
- package/lib/verify/explain-rule.mjs +54 -0
- package/lib/verify/issue-helpers.mjs +132 -0
- package/lib/verify/project-layout-verifier.mjs +259 -0
- package/lib/verify/project-verifier.mjs +267 -0
- package/lib/verify/readme-public-api.mjs +237 -0
- package/lib/verify/readme-verifier.mjs +216 -0
- package/lib/verify/render-json-report.mjs +3 -0
- package/lib/verify/render-text-report.mjs +34 -0
- package/lib/verify/source-analysis.mjs +126 -0
- package/lib/verify/source-rule-verifier.mjs +453 -0
- package/lib/verify/testing-verifier.mjs +113 -0
- package/lib/verify/tooling-verifier.mjs +82 -0
- package/lib/verify/typescript-style-verifier.mjs +407 -0
- package/package.json +55 -0
- package/profiles/default.profile.json +40 -0
- package/profiles/schema.json +96 -0
- package/prompts/init-contract.md +25 -0
- package/prompts/init-phase-2-implement.md +25 -0
- package/prompts/init-phase-3-verify.md +23 -0
- package/prompts/init.prompt.md +24 -0
- package/prompts/refactor-contract.md +26 -0
- package/prompts/refactor-phase-2-rebuild.md +25 -0
- package/prompts/refactor-phase-3-verify.md +24 -0
- package/prompts/refactor.prompt.md +26 -0
- package/resources/ai/AGENTS.md +18 -0
- package/resources/ai/adapters/codex.md +5 -0
- package/resources/ai/adapters/copilot.md +5 -0
- package/resources/ai/adapters/cursor.md +5 -0
- package/resources/ai/adapters/windsurf.md +5 -0
- package/resources/ai/contract.schema.json +68 -0
- package/resources/ai/rule-catalog.json +878 -0
- package/resources/ai/rule-catalog.schema.json +66 -0
- package/resources/ai/templates/adapters/codex.template.md +7 -0
- package/resources/ai/templates/adapters/copilot.template.md +7 -0
- package/resources/ai/templates/adapters/cursor.template.md +7 -0
- package/resources/ai/templates/adapters/windsurf.template.md +7 -0
- package/resources/ai/templates/agents.project.template.md +141 -0
- package/resources/ai/templates/examples/demo/src/billing/billing.service.ts +73 -0
- package/resources/ai/templates/examples/demo/src/config.ts +3 -0
- package/resources/ai/templates/examples/demo/src/invoice/invoice.errors.ts +51 -0
- package/resources/ai/templates/examples/demo/src/invoice/invoice.service.ts +96 -0
- package/resources/ai/templates/examples/demo/src/invoice/invoice.types.ts +9 -0
- package/resources/ai/templates/examples/rules/async-bad.ts +52 -0
- package/resources/ai/templates/examples/rules/async-good.ts +56 -0
- package/resources/ai/templates/examples/rules/class-first-bad.ts +36 -0
- package/resources/ai/templates/examples/rules/class-first-good.ts +74 -0
- package/resources/ai/templates/examples/rules/constructor-bad.ts +68 -0
- package/resources/ai/templates/examples/rules/constructor-good.ts +71 -0
- package/resources/ai/templates/examples/rules/control-flow-bad.ts +31 -0
- package/resources/ai/templates/examples/rules/control-flow-good.ts +54 -0
- package/resources/ai/templates/examples/rules/errors-bad.ts +42 -0
- package/resources/ai/templates/examples/rules/errors-good.ts +23 -0
- package/resources/ai/templates/examples/rules/functions-bad.ts +48 -0
- package/resources/ai/templates/examples/rules/functions-good.ts +58 -0
- package/resources/ai/templates/examples/rules/returns-bad.ts +38 -0
- package/resources/ai/templates/examples/rules/returns-good.ts +44 -0
- package/resources/ai/templates/examples/rules/testing-bad.ts +34 -0
- package/resources/ai/templates/examples/rules/testing-good.ts +54 -0
- package/resources/ai/templates/rules/architecture.md +41 -0
- package/resources/ai/templates/rules/async.md +13 -0
- package/resources/ai/templates/rules/class-first.md +45 -0
- package/resources/ai/templates/rules/control-flow.md +13 -0
- package/resources/ai/templates/rules/errors.md +18 -0
- package/resources/ai/templates/rules/functions.md +29 -0
- package/resources/ai/templates/rules/naming.md +13 -0
- package/resources/ai/templates/rules/readme.md +36 -0
- package/resources/ai/templates/rules/returns.md +13 -0
- package/resources/ai/templates/rules/testing.md +18 -0
- package/resources/ai/templates/rules.project.template.md +66 -0
- package/resources/ai/templates/skills/change-synchronization/SKILL.md +42 -0
- package/resources/ai/templates/skills/feature-shaping/SKILL.md +45 -0
- package/resources/ai/templates/skills/http-api-conventions/SKILL.md +171 -0
- package/resources/ai/templates/skills/init-workflow/SKILL.md +52 -0
- package/resources/ai/templates/skills/readme-authoring/SKILL.md +51 -0
- package/resources/ai/templates/skills/refactor-workflow/SKILL.md +50 -0
- package/resources/ai/templates/skills/simplicity-audit/SKILL.md +41 -0
- package/resources/ai/templates/skills/test-scope-selection/SKILL.md +50 -0
- package/resources/ai/templates/skills.index.template.md +25 -0
- package/standards/architecture.md +72 -0
- package/standards/changelog-policy.md +12 -0
- package/standards/manifest.json +36 -0
- package/standards/readme.md +56 -0
- package/standards/schema.json +124 -0
- package/standards/style.md +106 -0
- package/standards/testing.md +20 -0
- package/standards/tooling.md +38 -0
- package/templates/node-lib/.biomeignore +10 -0
- package/templates/node-lib/.vscode/extensions.json +1 -0
- package/templates/node-lib/.vscode/settings.json +9 -0
- package/templates/node-lib/README.md +172 -0
- package/templates/node-lib/biome.json +37 -0
- package/templates/node-lib/gitignore +6 -0
- package/templates/node-lib/package.json +32 -0
- package/templates/node-lib/scripts/release-publish.mjs +106 -0
- package/templates/node-lib/scripts/run-tests.mjs +65 -0
- package/templates/node-lib/src/config.ts +3 -0
- package/templates/node-lib/src/index.ts +2 -0
- package/templates/node-lib/src/logger.ts +7 -0
- package/templates/node-lib/src/package-info/package-info.service.ts +47 -0
- package/templates/node-lib/test/package-info.test.ts +10 -0
- package/templates/node-lib/tsconfig.build.json +1 -0
- package/templates/node-lib/tsconfig.json +5 -0
- package/templates/node-service/.biomeignore +10 -0
- package/templates/node-service/.vscode/extensions.json +1 -0
- package/templates/node-service/.vscode/settings.json +9 -0
- package/templates/node-service/README.md +244 -0
- package/templates/node-service/biome.json +37 -0
- package/templates/node-service/ecosystem.config.cjs +3 -0
- package/templates/node-service/gitignore +6 -0
- package/templates/node-service/package.json +42 -0
- package/templates/node-service/scripts/release-publish.mjs +106 -0
- package/templates/node-service/scripts/run-tests.mjs +65 -0
- package/templates/node-service/src/app/service-runtime.service.ts +57 -0
- package/templates/node-service/src/app-info/app-info.service.ts +47 -0
- package/templates/node-service/src/config.ts +11 -0
- package/templates/node-service/src/http/http-server.service.ts +66 -0
- package/templates/node-service/src/index.ts +2 -0
- package/templates/node-service/src/logger.ts +7 -0
- package/templates/node-service/src/main.ts +5 -0
- package/templates/node-service/test/service-runtime.test.ts +13 -0
- package/templates/node-service/tsconfig.build.json +1 -0
- package/templates/node-service/tsconfig.json +5 -0
- package/tsconfig/base.json +16 -0
- package/tsconfig/node-lib.json +5 -0
- package/tsconfig/node-service.json +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
import { createContractIssue, getActiveVerifyRuleIds, shouldRunRule } from "./issue-helpers.mjs";
|
|
4
|
+
import { getLineAndColumn } from "./source-analysis.mjs";
|
|
5
|
+
|
|
6
|
+
const GENERIC_ERROR_MESSAGE_PATTERN = /^(error|failed|invalid|unexpected|oops|bad request)$/i;
|
|
7
|
+
|
|
8
|
+
function createIssue(contract, ruleId, relativePath, node, message, options = {}) {
|
|
9
|
+
const location = node && "getStart" in node && "getSourceFile" in node ? getLineAndColumn(node.getSourceFile(), node.getStart(node.getSourceFile())) : {};
|
|
10
|
+
return createContractIssue(contract, {
|
|
11
|
+
ruleId,
|
|
12
|
+
category: "error-handling",
|
|
13
|
+
relativePath,
|
|
14
|
+
line: location.line,
|
|
15
|
+
column: location.column,
|
|
16
|
+
message,
|
|
17
|
+
verificationMode: options.verificationMode,
|
|
18
|
+
confidence: options.confidence,
|
|
19
|
+
evidence: options.evidence,
|
|
20
|
+
suggestion: options.suggestion,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extendsError(node) {
|
|
25
|
+
if (!node.heritageClauses) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return node.heritageClauses.some(
|
|
30
|
+
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.some((typeNode) => typeNode.expression.getText() === "Error"),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectTypedErrors(sourceFiles) {
|
|
35
|
+
const typedErrors = new Map();
|
|
36
|
+
|
|
37
|
+
for (const file of sourceFiles) {
|
|
38
|
+
ts.forEachChild(file.ast, (node) => {
|
|
39
|
+
if (ts.isClassDeclaration(node) && node.name && extendsError(node)) {
|
|
40
|
+
typedErrors.set(node.name.text, { file, node });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return typedErrors;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectErrorConsumers(sourceFiles) {
|
|
49
|
+
const consumers = new Set();
|
|
50
|
+
|
|
51
|
+
for (const file of sourceFiles) {
|
|
52
|
+
function visit(node) {
|
|
53
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword && ts.isIdentifier(node.right)) {
|
|
54
|
+
consumers.add(node.right.text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ts.forEachChild(node, visit);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
visit(file.ast);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return consumers;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasMeaningfulCatchHandling(catchClause) {
|
|
67
|
+
if (!catchClause.block.statements.length) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return catchClause.block.statements.some((statement) => {
|
|
72
|
+
if (ts.isThrowStatement(statement)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isPropertyAccessExpression(statement.expression.expression)) {
|
|
77
|
+
const propertyName = statement.expression.expression.name.text;
|
|
78
|
+
return propertyName === "error" || propertyName === "warn" || propertyName === "catch";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return false;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readThrownErrorMessage(expression) {
|
|
86
|
+
if (
|
|
87
|
+
ts.isNewExpression(expression) &&
|
|
88
|
+
ts.isIdentifier(expression.expression) &&
|
|
89
|
+
expression.arguments?.length === 1 &&
|
|
90
|
+
ts.isStringLiteralLike(expression.arguments[0])
|
|
91
|
+
) {
|
|
92
|
+
return expression.arguments[0].text;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function verifyErrorHandling(_targetPath, contract, analysisContext, options = {}) {
|
|
99
|
+
const activeRuleIds = getActiveVerifyRuleIds(contract);
|
|
100
|
+
const typedErrors = collectTypedErrors(analysisContext.sourceFiles);
|
|
101
|
+
const errorConsumers = collectErrorConsumers(analysisContext.sourceFiles);
|
|
102
|
+
const issues = [];
|
|
103
|
+
|
|
104
|
+
for (const file of analysisContext.sourceFiles) {
|
|
105
|
+
function visit(node) {
|
|
106
|
+
if (
|
|
107
|
+
activeRuleIds.has("no-silent-catch") &&
|
|
108
|
+
shouldRunRule(options.onlyRuleIds, "no-silent-catch") &&
|
|
109
|
+
ts.isCatchClause(node) &&
|
|
110
|
+
!hasMeaningfulCatchHandling(node)
|
|
111
|
+
) {
|
|
112
|
+
issues.push(
|
|
113
|
+
createIssue(
|
|
114
|
+
contract,
|
|
115
|
+
"no-silent-catch",
|
|
116
|
+
file.relativePath,
|
|
117
|
+
node,
|
|
118
|
+
"catch blocks must rethrow, transform, or report errors with throw, console.warn, console.error, or promise-style .catch handling",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
activeRuleIds.has("actionable-error-messages") &&
|
|
125
|
+
shouldRunRule(options.onlyRuleIds, "actionable-error-messages") &&
|
|
126
|
+
ts.isThrowStatement(node) &&
|
|
127
|
+
node.expression
|
|
128
|
+
) {
|
|
129
|
+
const errorMessage = readThrownErrorMessage(node.expression);
|
|
130
|
+
if (errorMessage !== null && (errorMessage.trim().length === 0 || GENERIC_ERROR_MESSAGE_PATTERN.test(errorMessage.trim()))) {
|
|
131
|
+
issues.push(
|
|
132
|
+
createIssue(contract, "actionable-error-messages", file.relativePath, node.expression, "error messages should include actionable context", {
|
|
133
|
+
verificationMode: "heuristic",
|
|
134
|
+
confidence: "medium",
|
|
135
|
+
evidence: JSON.stringify(errorMessage),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
ts.forEachChild(node, visit);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
visit(file.ast);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (activeRuleIds.has("typed-error-must-be-used") && shouldRunRule(options.onlyRuleIds, "typed-error-must-be-used")) {
|
|
148
|
+
for (const [errorName, descriptor] of typedErrors) {
|
|
149
|
+
if (!errorConsumers.has(errorName)) {
|
|
150
|
+
issues.push(
|
|
151
|
+
createIssue(
|
|
152
|
+
contract,
|
|
153
|
+
"typed-error-must-be-used",
|
|
154
|
+
descriptor.file.relativePath,
|
|
155
|
+
descriptor.node.name,
|
|
156
|
+
`custom error ${errorName} must have a real discriminating consumer such as instanceof`,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return issues;
|
|
164
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function renderProfileOverrides(profileOverrides) {
|
|
2
|
+
if (!profileOverrides || profileOverrides.length === 0) {
|
|
3
|
+
return "- none";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return profileOverrides
|
|
7
|
+
.map((override) => {
|
|
8
|
+
if (override.equals !== undefined) {
|
|
9
|
+
return `- ${override.key} == ${String(override.equals)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(override.oneOf)) {
|
|
13
|
+
return `- ${override.key} in [${override.oneOf.map((value) => String(value)).join(", ")}]`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return `- ${override.key}`;
|
|
17
|
+
})
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderExamples(label, examples) {
|
|
22
|
+
if (!examples || examples.length === 0) {
|
|
23
|
+
return `${label}:\n- none`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return `${label}:\n${examples.map((examplePath) => `- ${examplePath}`).join("\n")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function explainRule(rule) {
|
|
30
|
+
return `Rule: ${rule.id}
|
|
31
|
+
Title: ${rule.title}
|
|
32
|
+
Summary: ${rule.summary}
|
|
33
|
+
Kind: ${rule.kind}
|
|
34
|
+
Severity: ${rule.severity}
|
|
35
|
+
Deterministic: ${rule.deterministic ? "yes" : "no"}
|
|
36
|
+
Verification mode: ${rule.verificationMode}
|
|
37
|
+
Verification source: ${rule.verificationSource}
|
|
38
|
+
Confidence: ${rule.confidence}
|
|
39
|
+
Applies to:
|
|
40
|
+
${rule.appliesTo.map((pattern) => `- ${pattern}`).join("\n")}
|
|
41
|
+
|
|
42
|
+
Enforced by:
|
|
43
|
+
${rule.enforcedBy.map((enforcedBy) => `- ${enforcedBy}`).join("\n")}
|
|
44
|
+
|
|
45
|
+
Implemented by:
|
|
46
|
+
${(rule.implementedBy ?? []).map((implementedBy) => `- ${implementedBy}`).join("\n")}
|
|
47
|
+
|
|
48
|
+
Profile overrides:
|
|
49
|
+
${renderProfileOverrides(rule.profileOverrides)}
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
${renderExamples("Good", rule.examples.good)}
|
|
53
|
+
${renderExamples("Bad", rule.examples.bad)}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const PROJECT_VERIFY_RULE_IDS = [
|
|
4
|
+
"contract-presence",
|
|
5
|
+
"metadata-sync",
|
|
6
|
+
"managed-files",
|
|
7
|
+
"typescript-only",
|
|
8
|
+
"template-layout",
|
|
9
|
+
"readme-sections",
|
|
10
|
+
"readme-public-api",
|
|
11
|
+
"readme-public-methods",
|
|
12
|
+
"readme-http-api",
|
|
13
|
+
"readme-usage-examples",
|
|
14
|
+
"adapter-presence",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const FAILURE_SEVERITIES = new Set(["error"]);
|
|
18
|
+
export const STRICT_FAILURE_SEVERITIES = new Set(["error", "warning"]);
|
|
19
|
+
|
|
20
|
+
export function createVerificationIssue(options) {
|
|
21
|
+
const issue = {
|
|
22
|
+
ruleId: options.ruleId,
|
|
23
|
+
category: options.category,
|
|
24
|
+
severity: options.severity ?? "error",
|
|
25
|
+
message: options.message,
|
|
26
|
+
enforcedBy: "verify",
|
|
27
|
+
verificationMode: options.verificationMode ?? "deterministic",
|
|
28
|
+
confidence: options.confidence ?? "high",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (options.relativePath) {
|
|
32
|
+
issue.relativePath = options.relativePath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof options.line === "number") {
|
|
36
|
+
issue.line = options.line;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof options.column === "number") {
|
|
40
|
+
issue.column = options.column;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.evidence) {
|
|
44
|
+
issue.evidence = options.evidence;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.suggestion) {
|
|
48
|
+
issue.suggestion = options.suggestion;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return issue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function shouldRunRule(onlyRuleIds, ruleId) {
|
|
55
|
+
if (!onlyRuleIds || onlyRuleIds.length === 0) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return onlyRuleIds.includes(ruleId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getRuleMap(contract, predicate = () => true) {
|
|
63
|
+
return new Map(contract.rules.filter(predicate).map((rule) => [rule.id, rule]));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getVerifyRuleMap(contract) {
|
|
67
|
+
return getRuleMap(contract, (rule) => rule.enforcedBy.includes("verify"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getActiveVerifyRuleIds(contract) {
|
|
71
|
+
return new Set(getVerifyRuleMap(contract).keys());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getRuleSeverity(contract, ruleId, fallbackSeverity = "error") {
|
|
75
|
+
return getVerifyRuleMap(contract).get(ruleId)?.severity ?? fallbackSeverity;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createContractIssue(contract, options) {
|
|
79
|
+
return createVerificationIssue({
|
|
80
|
+
...options,
|
|
81
|
+
severity: options.severity ?? getRuleSeverity(contract, options.ruleId),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeRequestedFiles(targetPath, files) {
|
|
86
|
+
if (!files || files.length === 0) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const normalizedFiles = new Set();
|
|
91
|
+
|
|
92
|
+
for (const rawFile of files) {
|
|
93
|
+
const resolvedPath = path.isAbsolute(rawFile) ? rawFile : path.resolve(targetPath, rawFile);
|
|
94
|
+
const relativePath = path.relative(targetPath, resolvedPath).split(path.sep).join("/");
|
|
95
|
+
|
|
96
|
+
if (relativePath.length === 0 || relativePath === ".") {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
normalizedFiles.add(relativePath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [...normalizedFiles].sort((left, right) => left.localeCompare(right));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isFileSelected(selectedFiles, relativePath) {
|
|
107
|
+
if (!selectedFiles || selectedFiles.length === 0) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return selectedFiles.includes(relativePath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildVerificationResult(issues, checkedRuleIds, checkedFiles, options = {}) {
|
|
115
|
+
const failureSeverities = options.failureSeverities ?? (options.strict ? STRICT_FAILURE_SEVERITIES : FAILURE_SEVERITIES);
|
|
116
|
+
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
117
|
+
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
|
118
|
+
const auditCount = issues.filter((issue) => issue.severity === "audit").length;
|
|
119
|
+
return {
|
|
120
|
+
ok: !issues.some((issue) => failureSeverities.has(issue.severity)),
|
|
121
|
+
hasWarnings: warningCount > 0,
|
|
122
|
+
issues,
|
|
123
|
+
summary: {
|
|
124
|
+
issueCount: issues.length,
|
|
125
|
+
errorCount,
|
|
126
|
+
warningCount,
|
|
127
|
+
auditCount,
|
|
128
|
+
checkedRuleIds: [...new Set(checkedRuleIds)].sort((left, right) => left.localeCompare(right)),
|
|
129
|
+
checkedFiles: [...new Set(checkedFiles)].sort((left, right) => left.localeCompare(right)),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
import { createContractIssue, getActiveVerifyRuleIds, shouldRunRule } from "./issue-helpers.mjs";
|
|
6
|
+
import { resolveImportRelativePath } from "./source-analysis.mjs";
|
|
7
|
+
|
|
8
|
+
const RESERVED_ROOT_SOURCE_FILES = new Set(["config.ts", "index.ts", "logger.ts", "main.ts"]);
|
|
9
|
+
const RESERVED_FEATURE_FOLDERS = new Set(["app", "shared", "http", "public", "internal"]);
|
|
10
|
+
const AMBIGUOUS_FILE_BASENAMES = new Set(["helper", "helpers", "util", "utils", "common"]);
|
|
11
|
+
const KEBAB_CASE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
|
|
13
|
+
function getFeatureSegments(relativePath) {
|
|
14
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
15
|
+
return normalizedPath.split("/");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createIssue(contract, ruleId, relativePath, message, options = {}) {
|
|
19
|
+
return createContractIssue(contract, {
|
|
20
|
+
ruleId,
|
|
21
|
+
category: "project-layout",
|
|
22
|
+
relativePath,
|
|
23
|
+
message,
|
|
24
|
+
verificationMode: options.verificationMode,
|
|
25
|
+
confidence: options.confidence,
|
|
26
|
+
evidence: options.evidence,
|
|
27
|
+
suggestion: options.suggestion,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectSharedConsumers(sourceFiles) {
|
|
32
|
+
const consumers = new Set();
|
|
33
|
+
|
|
34
|
+
for (const file of sourceFiles) {
|
|
35
|
+
if (!file.relativePath.startsWith("src/") || !file.featureName || file.featureName === "shared") {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const statement of file.ast.statements) {
|
|
40
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const importedPath = resolveImportRelativePath(file.relativePath, statement.moduleSpecifier.text);
|
|
45
|
+
if (importedPath?.startsWith("src/shared/")) {
|
|
46
|
+
consumers.add(file.featureName);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return consumers;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectExportedTypeCount(file) {
|
|
55
|
+
let exportedTypeCount = 0;
|
|
56
|
+
|
|
57
|
+
for (const statement of file.ast.statements) {
|
|
58
|
+
if (
|
|
59
|
+
(ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isEnumDeclaration(statement)) &&
|
|
60
|
+
Array.isArray(statement.modifiers) &&
|
|
61
|
+
statement.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
62
|
+
) {
|
|
63
|
+
exportedTypeCount += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return exportedTypeCount;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function verifyProjectLayout(_targetPath, contract, analysisContext, options = {}) {
|
|
71
|
+
const activeRuleIds = getActiveVerifyRuleIds(contract);
|
|
72
|
+
const issues = [];
|
|
73
|
+
const files = analysisContext.sourceFiles.map((file) => file.relativePath);
|
|
74
|
+
const sharedConsumers = collectSharedConsumers(analysisContext.sourceFiles);
|
|
75
|
+
const featureEntrypoints = new Set(
|
|
76
|
+
files.filter((relativePath) => relativePath.startsWith("src/") && relativePath.endsWith("/index.ts")).map((relativePath) => relativePath.split("/")[1]),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
for (const relativePath of files) {
|
|
80
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
81
|
+
const segments = getFeatureSegments(relativePath);
|
|
82
|
+
const fileName = segments.at(-1);
|
|
83
|
+
const fileBaseName = fileName.replace(/\.ts$/, "");
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
(normalizedPath.startsWith("src/") || normalizedPath.startsWith("test/")) &&
|
|
87
|
+
shouldRunRule(options.onlyRuleIds, "kebab-case-paths") &&
|
|
88
|
+
activeRuleIds.has("kebab-case-paths")
|
|
89
|
+
) {
|
|
90
|
+
for (const segment of segments.slice(1)) {
|
|
91
|
+
const segmentBaseName = segment
|
|
92
|
+
.replace(/\.test\.ts$/, "")
|
|
93
|
+
.replace(/\.ts$/, "")
|
|
94
|
+
.replace(/\.[a-z]+$/, "");
|
|
95
|
+
if (!segmentBaseName || RESERVED_ROOT_SOURCE_FILES.has(segment) || RESERVED_FEATURE_FOLDERS.has(segmentBaseName)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!KEBAB_CASE_PATTERN.test(segmentBaseName)) {
|
|
100
|
+
issues.push(createIssue(contract, "kebab-case-paths", relativePath, `path segment must use kebab-case: ${segment}`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
normalizedPath.startsWith("src/") &&
|
|
107
|
+
segments.length === 2 &&
|
|
108
|
+
!RESERVED_ROOT_SOURCE_FILES.has(fileName) &&
|
|
109
|
+
shouldRunRule(options.onlyRuleIds, "feature-first-layout") &&
|
|
110
|
+
activeRuleIds.has("feature-first-layout")
|
|
111
|
+
) {
|
|
112
|
+
issues.push(
|
|
113
|
+
createIssue(
|
|
114
|
+
contract,
|
|
115
|
+
"feature-first-layout",
|
|
116
|
+
relativePath,
|
|
117
|
+
"src/ root should only keep entrypoints and shared scaffold files when feature folders exist",
|
|
118
|
+
{
|
|
119
|
+
verificationMode: "heuristic",
|
|
120
|
+
confidence: "medium",
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
normalizedPath.startsWith("src/") &&
|
|
128
|
+
segments.length >= 3 &&
|
|
129
|
+
shouldRunRule(options.onlyRuleIds, "ambiguous-feature-filenames") &&
|
|
130
|
+
activeRuleIds.has("ambiguous-feature-filenames")
|
|
131
|
+
) {
|
|
132
|
+
const baseWithoutRole = fileBaseName.split(".")[0];
|
|
133
|
+
if (AMBIGUOUS_FILE_BASENAMES.has(baseWithoutRole)) {
|
|
134
|
+
issues.push(createIssue(contract, "ambiguous-feature-filenames", relativePath, `ambiguous feature filename is forbidden: ${fileName}`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
normalizedPath.startsWith("test/") &&
|
|
140
|
+
shouldRunRule(options.onlyRuleIds, "test-file-naming") &&
|
|
141
|
+
activeRuleIds.has("test-file-naming") &&
|
|
142
|
+
!normalizedPath.endsWith(".test.ts")
|
|
143
|
+
) {
|
|
144
|
+
issues.push(createIssue(contract, "test-file-naming", relativePath, "test files must use the .test.ts suffix"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (shouldRunRule(options.onlyRuleIds, "singular-feature-folders") && activeRuleIds.has("singular-feature-folders")) {
|
|
149
|
+
const featureFolders = new Set(
|
|
150
|
+
files
|
|
151
|
+
.filter((relativePath) => relativePath.startsWith("src/"))
|
|
152
|
+
.map((relativePath) => relativePath.split("/")[1])
|
|
153
|
+
.filter((segment) => segment && !RESERVED_FEATURE_FOLDERS.has(segment) && !segment.endsWith(".ts")),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
for (const featureFolder of featureFolders) {
|
|
157
|
+
if (featureFolder.endsWith("s")) {
|
|
158
|
+
issues.push(createIssue(contract, "singular-feature-folders", `src/${featureFolder}`, `feature folders must be singular: ${featureFolder}`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (shouldRunRule(options.onlyRuleIds, "restricted-shared-boundaries") && activeRuleIds.has("restricted-shared-boundaries")) {
|
|
164
|
+
if (files.some((relativePath) => relativePath.startsWith("src/shared/")) && sharedConsumers.size < 2) {
|
|
165
|
+
issues.push(
|
|
166
|
+
createIssue(contract, "restricted-shared-boundaries", "src/shared", "src/shared should exist only when at least two features consume it", {
|
|
167
|
+
verificationMode: "heuristic",
|
|
168
|
+
confidence: "medium",
|
|
169
|
+
evidence: `${sharedConsumers.size} feature consumer(s) found`,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (files.some((relativePath) => relativePath.startsWith("src/app/"))) {
|
|
175
|
+
const hasCompositionSurface = files.some(
|
|
176
|
+
(relativePath) => relativePath.startsWith("src/app/") && /(runtime|bootstrap|compose|wiring|container|module|app)\./.test(relativePath),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (!hasCompositionSurface) {
|
|
180
|
+
issues.push(
|
|
181
|
+
createIssue(contract, "restricted-shared-boundaries", "src/app", "src/app should be reserved for real composition and wiring concerns", {
|
|
182
|
+
verificationMode: "heuristic",
|
|
183
|
+
confidence: "medium",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
(shouldRunRule(options.onlyRuleIds, "cross-feature-entrypoint-imports") && activeRuleIds.has("cross-feature-entrypoint-imports")) ||
|
|
192
|
+
(shouldRunRule(options.onlyRuleIds, "types-file-justification") && activeRuleIds.has("types-file-justification"))
|
|
193
|
+
) {
|
|
194
|
+
for (const file of analysisContext.sourceFiles) {
|
|
195
|
+
if (!file.relativePath.startsWith("src/")) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const statement of file.ast.statements) {
|
|
200
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const importedRelativePath = resolveImportRelativePath(file.relativePath, statement.moduleSpecifier.text);
|
|
205
|
+
if (!importedRelativePath?.startsWith("src/")) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const importedSegments = importedRelativePath.split("/");
|
|
210
|
+
const importedFeatureName = importedSegments[1];
|
|
211
|
+
const targetFileName = importedSegments.at(-1);
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
shouldRunRule(options.onlyRuleIds, "cross-feature-entrypoint-imports") &&
|
|
215
|
+
activeRuleIds.has("cross-feature-entrypoint-imports") &&
|
|
216
|
+
file.featureName &&
|
|
217
|
+
importedFeatureName &&
|
|
218
|
+
file.featureName !== importedFeatureName &&
|
|
219
|
+
featureEntrypoints.has(importedFeatureName) &&
|
|
220
|
+
!RESERVED_FEATURE_FOLDERS.has(importedFeatureName) &&
|
|
221
|
+
!RESERVED_ROOT_SOURCE_FILES.has(targetFileName) &&
|
|
222
|
+
targetFileName !== "index.ts"
|
|
223
|
+
) {
|
|
224
|
+
issues.push(
|
|
225
|
+
createIssue(
|
|
226
|
+
contract,
|
|
227
|
+
"cross-feature-entrypoint-imports",
|
|
228
|
+
file.relativePath,
|
|
229
|
+
`cross-feature import must go through ${importedFeatureName}/index.ts instead of ${importedRelativePath}`,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
shouldRunRule(options.onlyRuleIds, "types-file-justification") &&
|
|
237
|
+
activeRuleIds.has("types-file-justification") &&
|
|
238
|
+
file.relativePath.endsWith(".types.ts")
|
|
239
|
+
) {
|
|
240
|
+
const exportedTypeCount = collectExportedTypeCount(file);
|
|
241
|
+
const externalImportCount = analysisContext.sourceFiles.filter(
|
|
242
|
+
(candidate) => candidate.relativePath !== file.relativePath && candidate.raw.includes(`"${path.posix.basename(file.relativePath, ".ts")}.ts"`),
|
|
243
|
+
).length;
|
|
244
|
+
|
|
245
|
+
if (exportedTypeCount <= 1 && externalImportCount === 0) {
|
|
246
|
+
issues.push(
|
|
247
|
+
createIssue(contract, "types-file-justification", file.relativePath, ".types.ts should be reserved for substantial shared feature types", {
|
|
248
|
+
verificationMode: "heuristic",
|
|
249
|
+
confidence: "medium",
|
|
250
|
+
evidence: `${exportedTypeCount} exported type(s), ${externalImportCount} external import(s)`,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return issues;
|
|
259
|
+
}
|