@sha3/code-standards 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +50 -9
  2. package/bin/code-standards.mjs +32 -37
  3. package/package.json +1 -1
  4. package/resources/ai/templates/examples/demo/src/billing/billing-service.ts +102 -0
  5. package/resources/ai/templates/examples/demo/src/invoices/invoice-errors.ts +89 -0
  6. package/resources/ai/templates/examples/demo/src/invoices/invoice-service.ts +123 -0
  7. package/resources/ai/templates/examples/demo/src/invoices/invoice-types.ts +20 -0
  8. package/resources/ai/templates/examples/rules/async-bad.ts +94 -0
  9. package/resources/ai/templates/examples/rules/async-good.ts +94 -0
  10. package/resources/ai/templates/examples/rules/class-first-bad.ts +90 -0
  11. package/resources/ai/templates/examples/rules/class-first-good.ts +99 -0
  12. package/resources/ai/templates/examples/rules/constructor-bad.ts +98 -0
  13. package/resources/ai/templates/examples/rules/constructor-good.ts +97 -0
  14. package/resources/ai/templates/examples/rules/control-flow-bad.ts +85 -0
  15. package/resources/ai/templates/examples/rules/control-flow-good.ts +92 -0
  16. package/resources/ai/templates/examples/rules/errors-bad.ts +86 -0
  17. package/resources/ai/templates/examples/rules/errors-good.ts +89 -0
  18. package/resources/ai/templates/examples/rules/functions-bad.ts +106 -0
  19. package/resources/ai/templates/examples/rules/functions-good.ts +102 -0
  20. package/resources/ai/templates/examples/rules/returns-bad.ts +92 -0
  21. package/resources/ai/templates/examples/rules/returns-good.ts +94 -0
  22. package/resources/ai/templates/examples/rules/testing-bad.ts +88 -0
  23. package/resources/ai/templates/examples/rules/testing-good.ts +92 -0
  24. package/resources/ai/templates/rules/architecture.md +5 -15
  25. package/resources/ai/templates/rules/async.md +2 -12
  26. package/resources/ai/templates/rules/class-first.md +23 -82
  27. package/resources/ai/templates/rules/control-flow.md +2 -8
  28. package/resources/ai/templates/rules/errors.md +2 -11
  29. package/resources/ai/templates/rules/functions.md +4 -15
  30. package/resources/ai/templates/rules/returns.md +2 -22
  31. package/resources/ai/templates/rules/testing.md +3 -14
  32. package/standards/architecture.md +1 -1
  33. package/standards/style.md +11 -1
  34. package/standards/testing.md +1 -1
  35. package/templates/node-lib/gitignore +5 -0
  36. package/templates/node-service/gitignore +5 -0
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  If you just want to start now:
19
19
 
20
20
  ```bash
21
- npx @sha3/code-standards init my-api --template node-service --yes
21
+ npx @sha3/code-standards init --template node-service --yes
22
22
  ```
23
23
 
24
24
  Then in your AI chat, paste this:
@@ -64,7 +64,9 @@ Default generated contract includes structural class/file blocks such as:
64
64
 
65
65
  Section marker format is fixed to:
66
66
 
67
- - `/** @section <block-name> */`
67
+ - `/**`
68
+ - ` * @section <block-name>`
69
+ - ` */`
68
70
 
69
71
  All blocks MUST exist even when empty (`// empty`).
70
72
 
@@ -85,14 +87,14 @@ No.
85
87
  Default flow (no profile setup):
86
88
 
87
89
  ```bash
88
- npx @sha3/code-standards init my-app --template node-service --yes
90
+ npx @sha3/code-standards init --template node-service --yes
89
91
  ```
90
92
 
91
93
  Custom profile flow:
92
94
 
93
95
  ```bash
94
96
  npx @sha3/code-standards profile --profile ./profiles/team.profile.json
95
- npx @sha3/code-standards init my-app --template node-service --yes --profile ./profiles/team.profile.json
97
+ npx @sha3/code-standards init --template node-service --yes --profile ./profiles/team.profile.json
96
98
  ```
97
99
 
98
100
  ---
@@ -103,10 +105,45 @@ After `init`, your new repo contains:
103
105
 
104
106
  - `AGENTS.md` (blocking rules for AI)
105
107
  - `ai/codex.md`, `ai/cursor.md`, `ai/copilot.md`, `ai/windsurf.md`
108
+ - `ai/examples/rules/*.ts` (good/bad examples per rule)
109
+ - `ai/examples/demo/src/*` (feature-folder demo with classes and section blocks)
110
+ - `.gitignore` preconfigured for Node/TypeScript output
106
111
  - lint/format/typecheck/test-ready project template
107
112
 
108
113
  That means the next step is **not** configuring tools. The next step is telling your assistant to obey `AGENTS.md` before coding.
109
114
 
115
+ Generated project code is TypeScript-only: implementation and tests live in `.ts` files.
116
+
117
+ ## TypeScript Example Files
118
+
119
+ `init` now stores code examples in `.ts` files instead of embedding them inside `AGENTS.md`.
120
+
121
+ Demo structure generated by default:
122
+
123
+ - `ai/examples/demo/src/invoices/invoice-service.ts`
124
+ - `ai/examples/demo/src/invoices/invoice-errors.ts`
125
+ - `ai/examples/demo/src/invoices/invoice-types.ts`
126
+ - `ai/examples/demo/src/billing/billing-service.ts`
127
+
128
+ Rule-specific examples:
129
+
130
+ - `ai/examples/rules/class-first-good.ts`
131
+ - `ai/examples/rules/class-first-bad.ts`
132
+ - `ai/examples/rules/constructor-good.ts`
133
+ - `ai/examples/rules/constructor-bad.ts`
134
+ - `ai/examples/rules/functions-good.ts`
135
+ - `ai/examples/rules/functions-bad.ts`
136
+ - `ai/examples/rules/returns-good.ts`
137
+ - `ai/examples/rules/returns-bad.ts`
138
+ - `ai/examples/rules/async-good.ts`
139
+ - `ai/examples/rules/async-bad.ts`
140
+ - `ai/examples/rules/control-flow-good.ts`
141
+ - `ai/examples/rules/control-flow-bad.ts`
142
+ - `ai/examples/rules/errors-good.ts`
143
+ - `ai/examples/rules/errors-bad.ts`
144
+ - `ai/examples/rules/testing-good.ts`
145
+ - `ai/examples/rules/testing-bad.ts`
146
+
110
147
  ---
111
148
 
112
149
  ## How To Use With AI (Copy/Paste)
@@ -197,14 +234,16 @@ npx @sha3/code-standards profile \
197
234
 
198
235
  ### 2) Scaffold project
199
236
 
237
+ Run this inside the directory you want to initialize.
238
+
200
239
  ```bash
201
- npx @sha3/code-standards init my-api --template node-service --yes
240
+ npx @sha3/code-standards init --template node-service --yes
202
241
  ```
203
242
 
204
243
  With explicit profile:
205
244
 
206
245
  ```bash
207
- npx @sha3/code-standards init my-lib \
246
+ npx @sha3/code-standards init \
208
247
  --template node-lib \
209
248
  --yes \
210
249
  --no-install \
@@ -214,7 +253,7 @@ npx @sha3/code-standards init my-lib \
214
253
  Skip AI files when needed:
215
254
 
216
255
  ```bash
217
- npx @sha3/code-standards init my-lib --template node-lib --yes --no-ai-adapters
256
+ npx @sha3/code-standards init --template node-lib --yes --no-ai-adapters
218
257
  ```
219
258
 
220
259
  ### 3) Work loop inside generated project
@@ -234,15 +273,17 @@ Then use the prompts above in your AI tool.
234
273
  code-standards <command> [options]
235
274
 
236
275
  Commands:
237
- init [project-name] Initialize a project from templates
276
+ init Initialize a project in the current directory
238
277
  profile Create or update the AI style profile
239
278
  ```
240
279
 
241
280
  ### `init` options
242
281
 
282
+ `init` always uses the current working directory as target.
283
+ An existing `.git/` directory is allowed without `--force`.
284
+
243
285
  - `--template <node-lib|node-service>`
244
286
  - `--yes`
245
- - `--target <dir>`
246
287
  - `--no-install`
247
288
  - `--force`
248
289
  - `--with-ai-adapters`
@@ -166,13 +166,12 @@ function printUsage() {
166
166
  code-standards <command> [options]
167
167
 
168
168
  Commands:
169
- init [project-name] Initialize a project from templates
169
+ init Initialize a project in the current directory
170
170
  profile Create or update the AI style profile
171
171
 
172
172
  Init options:
173
173
  --template <node-lib|node-service>
174
174
  --yes
175
- --target <dir>
176
175
  --no-install
177
176
  --force
178
177
  --with-ai-adapters
@@ -192,11 +191,9 @@ function parseInitArgs(argv) {
192
191
  const options = {
193
192
  template: undefined,
194
193
  yes: false,
195
- target: undefined,
196
194
  install: true,
197
195
  force: false,
198
196
  withAiAdapters: true,
199
- projectName: undefined,
200
197
  profilePath: undefined,
201
198
  help: false
202
199
  };
@@ -205,12 +202,9 @@ function parseInitArgs(argv) {
205
202
  const token = argv[i];
206
203
 
207
204
  if (!token.startsWith("-")) {
208
- if (!options.projectName) {
209
- options.projectName = token;
210
- continue;
211
- }
212
-
213
- throw new Error(`Unexpected positional argument: ${token}`);
205
+ throw new Error(
206
+ `Positional project names are not supported: ${token}. Run init from your target directory.`
207
+ );
214
208
  }
215
209
 
216
210
  if (token === "--template") {
@@ -230,15 +224,7 @@ function parseInitArgs(argv) {
230
224
  }
231
225
 
232
226
  if (token === "--target") {
233
- const value = argv[i + 1];
234
-
235
- if (!value || value.startsWith("-")) {
236
- throw new Error("Missing value for --target");
237
- }
238
-
239
- options.target = value;
240
- i += 1;
241
- continue;
227
+ throw new Error("--target is not supported. Run init from your target directory.");
242
228
  }
243
229
 
244
230
  if (token === "--profile") {
@@ -354,6 +340,15 @@ function replaceTokens(content, tokens) {
354
340
  return output;
355
341
  }
356
342
 
343
+ function mapTemplateFileName(fileName) {
344
+ // npm may rewrite .gitignore to .npmignore in published tarballs.
345
+ if (fileName === "gitignore" || fileName === ".npmignore") {
346
+ return ".gitignore";
347
+ }
348
+
349
+ return fileName;
350
+ }
351
+
357
352
  function normalizeProfile(rawProfile) {
358
353
  const normalized = {};
359
354
 
@@ -399,8 +394,9 @@ async function ensureTargetReady(targetPath, force) {
399
394
  }
400
395
 
401
396
  const entries = await readdir(targetPath);
397
+ const nonGitEntries = entries.filter((entry) => entry !== ".git");
402
398
 
403
- if (entries.length > 0 && !force) {
399
+ if (nonGitEntries.length > 0 && !force) {
404
400
  throw new Error(
405
401
  `Target directory is not empty: ${targetPath}. Use --force to continue and overwrite files.`
406
402
  );
@@ -413,9 +409,9 @@ async function copyTemplateDirectory(sourceDir, targetDir, tokens) {
413
409
 
414
410
  for (const entry of entries) {
415
411
  const sourcePath = path.join(sourceDir, entry.name);
416
- const targetPath = path.join(targetDir, entry.name);
417
412
 
418
413
  if (entry.isDirectory()) {
414
+ const targetPath = path.join(targetDir, entry.name);
419
415
  await copyTemplateDirectory(sourcePath, targetPath, tokens);
420
416
  continue;
421
417
  }
@@ -426,6 +422,8 @@ async function copyTemplateDirectory(sourceDir, targetDir, tokens) {
426
422
 
427
423
  const raw = await readFile(sourcePath, "utf8");
428
424
  const rendered = replaceTokens(raw, tokens);
425
+ const targetName = mapTemplateFileName(entry.name);
426
+ const targetPath = path.join(targetDir, targetName);
429
427
  await writeFile(targetPath, rendered, "utf8");
430
428
  }
431
429
  }
@@ -790,9 +788,16 @@ async function renderAdapterFiles(packageRoot, targetDir, tokens) {
790
788
  }
791
789
  }
792
790
 
791
+ async function renderExampleFiles(packageRoot, targetDir, tokens) {
792
+ const examplesTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "examples");
793
+ const examplesTarget = path.join(targetDir, "ai", "examples");
794
+ await copyTemplateDirectory(examplesTemplateDir, examplesTarget, tokens);
795
+ }
796
+
793
797
  async function generateAiInstructions(packageRoot, targetDir, tokens, profile) {
794
798
  await renderProjectAgents(packageRoot, targetDir, tokens.projectName, profile);
795
799
  await renderAdapterFiles(packageRoot, targetDir, tokens);
800
+ await renderExampleFiles(packageRoot, targetDir, tokens);
796
801
  }
797
802
 
798
803
  async function maybeInitializeProfileInteractively(packageRoot, profilePath) {
@@ -881,17 +886,6 @@ async function promptForMissing(options) {
881
886
  resolved.template = normalized;
882
887
  }
883
888
 
884
- if (!resolved.projectName) {
885
- const nameAnswer = await rl.question("Project name [my-project]: ");
886
- resolved.projectName = nameAnswer.trim() || "my-project";
887
- }
888
-
889
- if (!resolved.target) {
890
- const targetDefault = resolved.projectName;
891
- const targetAnswer = await rl.question(`Target directory [${targetDefault}]: `);
892
- resolved.target = targetAnswer.trim() || targetDefault;
893
- }
894
-
895
889
  if (options.install) {
896
890
  const installAnswer = await rl.question("Install dependencies now? (Y/n): ");
897
891
  const normalized = installAnswer.trim().toLowerCase();
@@ -914,10 +908,12 @@ async function validateInitResources(packageRoot, templateName) {
914
908
  "agents.project.template.md"
915
909
  );
916
910
  const adaptersTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "adapters");
911
+ const examplesTemplateDir = path.join(packageRoot, "resources", "ai", "templates", "examples");
917
912
 
918
913
  await access(templateDir, constants.R_OK);
919
914
  await access(agentsTemplatePath, constants.R_OK);
920
915
  await access(adaptersTemplateDir, constants.R_OK);
916
+ await access(examplesTemplateDir, constants.R_OK);
921
917
 
922
918
  return { templateDir };
923
919
  }
@@ -932,8 +928,6 @@ async function runInit(rawOptions) {
932
928
 
933
929
  if (options.yes) {
934
930
  options.template ??= "node-lib";
935
- options.projectName ??= "my-project";
936
- options.target ??= options.projectName;
937
931
  } else {
938
932
  options = await promptForMissing(options);
939
933
  }
@@ -948,9 +942,11 @@ async function runInit(rawOptions) {
948
942
  const schema = await loadProfileSchema(packageRoot);
949
943
  const profile = await resolveProfileForInit(packageRoot, options, schema);
950
944
 
951
- const projectName = options.projectName ?? path.basename(options.target ?? "my-project");
945
+ const targetPath = path.resolve(process.cwd());
946
+ const inferredProjectName = path.basename(targetPath);
947
+ const projectName =
948
+ inferredProjectName && inferredProjectName !== path.sep ? inferredProjectName : "my-project";
952
949
  const packageName = sanitizePackageName(projectName);
953
- const targetPath = path.resolve(process.cwd(), options.target ?? projectName);
954
950
 
955
951
  await ensureTargetReady(targetPath, options.force);
956
952
 
@@ -975,7 +971,6 @@ async function runInit(rawOptions) {
975
971
 
976
972
  console.log(`Project created at ${targetPath}`);
977
973
  console.log("Next steps:");
978
- console.log(` cd ${targetPath}`);
979
974
  console.log(" npm run check");
980
975
  }
981
976
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sha3/code-standards",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "AI-first code standards, tooling exports, and project initializer",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @section imports:externals
3
+ */
4
+
5
+ // empty
6
+
7
+ /**
8
+ * @section imports:internals
9
+ */
10
+
11
+ import type { InvoiceService } from "../invoices/invoice-service.js";
12
+ import type { InvoiceSummary } from "../invoices/invoice-types.js";
13
+
14
+ /**
15
+ * @section consts
16
+ */
17
+
18
+ const CURRENCY_SYMBOL = "$";
19
+
20
+ /**
21
+ * @section types
22
+ */
23
+
24
+ export type BillingSnapshot = {
25
+ customerId: string;
26
+ invoiceCount: number;
27
+ totalAmount: number;
28
+ formattedTotal: string;
29
+ };
30
+
31
+ export class BillingService {
32
+ /**
33
+ * @section private:attributes
34
+ */
35
+
36
+ // empty
37
+
38
+ /**
39
+ * @section private:properties
40
+ */
41
+
42
+ private readonly invoiceService: InvoiceService;
43
+
44
+ /**
45
+ * @section public:properties
46
+ */
47
+
48
+ // empty
49
+
50
+ /**
51
+ * @section constructor
52
+ */
53
+
54
+ public constructor(invoiceService: InvoiceService) {
55
+ this.invoiceService = invoiceService;
56
+ }
57
+
58
+ /**
59
+ * @section static:properties
60
+ */
61
+
62
+ // empty
63
+
64
+ /**
65
+ * @section factory
66
+ */
67
+
68
+ public static create(invoiceService: InvoiceService): BillingService {
69
+ const service = new BillingService(invoiceService);
70
+ return service;
71
+ }
72
+
73
+ /**
74
+ * @section private:methods
75
+ */
76
+
77
+ private formatCurrency(amount: number): string {
78
+ const formattedAmount = `${CURRENCY_SYMBOL}${amount.toFixed(2)}`;
79
+ return formattedAmount;
80
+ }
81
+
82
+ /**
83
+ * @section public:methods
84
+ */
85
+
86
+ public async snapshot(customerId: string): Promise<BillingSnapshot> {
87
+ const summary: InvoiceSummary = await this.invoiceService.summarizeForCustomer(customerId);
88
+ const snapshot: BillingSnapshot = {
89
+ customerId,
90
+ invoiceCount: summary.count,
91
+ totalAmount: summary.totalAmount,
92
+ formattedTotal: this.formatCurrency(summary.totalAmount)
93
+ };
94
+ return snapshot;
95
+ }
96
+
97
+ /**
98
+ * @section static:methods
99
+ */
100
+
101
+ // empty
102
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @section imports:externals
3
+ */
4
+
5
+ // empty
6
+
7
+ /**
8
+ * @section imports:internals
9
+ */
10
+
11
+ // empty
12
+
13
+ /**
14
+ * @section consts
15
+ */
16
+
17
+ // empty
18
+
19
+ /**
20
+ * @section types
21
+ */
22
+
23
+ // empty
24
+
25
+ export class InvalidInvoiceCommandError extends Error {
26
+ /**
27
+ * @section private:attributes
28
+ */
29
+
30
+ // empty
31
+
32
+ /**
33
+ * @section private:properties
34
+ */
35
+
36
+ private readonly reason: string;
37
+
38
+ /**
39
+ * @section public:properties
40
+ */
41
+
42
+ // empty
43
+
44
+ /**
45
+ * @section constructor
46
+ */
47
+
48
+ public constructor(reason: string) {
49
+ super(`Invalid invoice command: ${reason}`);
50
+ this.name = "InvalidInvoiceCommandError";
51
+ this.reason = reason;
52
+ }
53
+
54
+ /**
55
+ * @section static:properties
56
+ */
57
+
58
+ // empty
59
+
60
+ /**
61
+ * @section factory
62
+ */
63
+
64
+ public static forReason(reason: string): InvalidInvoiceCommandError {
65
+ const error = new InvalidInvoiceCommandError(reason);
66
+ return error;
67
+ }
68
+
69
+ /**
70
+ * @section private:methods
71
+ */
72
+
73
+ // empty
74
+
75
+ /**
76
+ * @section public:methods
77
+ */
78
+
79
+ public getReason(): string {
80
+ const value = this.reason;
81
+ return value;
82
+ }
83
+
84
+ /**
85
+ * @section static:methods
86
+ */
87
+
88
+ // empty
89
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @section imports:externals
3
+ */
4
+
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ /**
8
+ * @section imports:internals
9
+ */
10
+
11
+ import { InvalidInvoiceCommandError } from "./invoice-errors.js";
12
+ import type { CreateInvoiceCommand, Invoice, InvoiceSummary } from "./invoice-types.js";
13
+
14
+ /**
15
+ * @section consts
16
+ */
17
+
18
+ const MINIMUM_INVOICE_AMOUNT = 0;
19
+
20
+ /**
21
+ * @section types
22
+ */
23
+
24
+ // empty
25
+
26
+ export class InvoiceService {
27
+ /**
28
+ * @section private:attributes
29
+ */
30
+
31
+ // empty
32
+
33
+ /**
34
+ * @section private:properties
35
+ */
36
+
37
+ private readonly invoicesById: Map<string, Invoice>;
38
+
39
+ /**
40
+ * @section public:properties
41
+ */
42
+
43
+ // empty
44
+
45
+ /**
46
+ * @section constructor
47
+ */
48
+
49
+ public constructor() {
50
+ this.invoicesById = new Map<string, Invoice>();
51
+ }
52
+
53
+ /**
54
+ * @section static:properties
55
+ */
56
+
57
+ // empty
58
+
59
+ /**
60
+ * @section factory
61
+ */
62
+
63
+ public static create(): InvoiceService {
64
+ const service = new InvoiceService();
65
+ return service;
66
+ }
67
+
68
+ /**
69
+ * @section private:methods
70
+ */
71
+
72
+ private validate(command: CreateInvoiceCommand): void {
73
+ if (!command.customerId.trim()) {
74
+ throw InvalidInvoiceCommandError.forReason("customerId is required");
75
+ }
76
+
77
+ if (command.amount <= MINIMUM_INVOICE_AMOUNT) {
78
+ throw InvalidInvoiceCommandError.forReason("amount must be greater than zero");
79
+ }
80
+ }
81
+
82
+ private toInvoice(command: CreateInvoiceCommand): Invoice {
83
+ const invoice: Invoice = {
84
+ id: randomUUID(),
85
+ customerId: command.customerId,
86
+ amount: command.amount,
87
+ issuedAt: new Date()
88
+ };
89
+ return invoice;
90
+ }
91
+
92
+ /**
93
+ * @section public:methods
94
+ */
95
+
96
+ public async create(command: CreateInvoiceCommand): Promise<Invoice> {
97
+ this.validate(command);
98
+ const createdInvoice: Invoice = this.toInvoice(command);
99
+ this.invoicesById.set(createdInvoice.id, createdInvoice);
100
+ return createdInvoice;
101
+ }
102
+
103
+ public async summarizeForCustomer(customerId: string): Promise<InvoiceSummary> {
104
+ const allInvoices: Invoice[] = Array.from(this.invoicesById.values());
105
+ const invoices: Invoice[] = allInvoices.filter((invoice) => {
106
+ return invoice.customerId === customerId;
107
+ });
108
+ const totalAmount = invoices.reduce((sum, invoice) => {
109
+ return sum + invoice.amount;
110
+ }, 0);
111
+ const summary: InvoiceSummary = {
112
+ count: invoices.length,
113
+ totalAmount
114
+ };
115
+ return summary;
116
+ }
117
+
118
+ /**
119
+ * @section static:methods
120
+ */
121
+
122
+ // empty
123
+ }
@@ -0,0 +1,20 @@
1
+ export type InvoiceId = string;
2
+
3
+ export type CustomerId = string;
4
+
5
+ export type Invoice = {
6
+ id: InvoiceId;
7
+ customerId: CustomerId;
8
+ amount: number;
9
+ issuedAt: Date;
10
+ };
11
+
12
+ export type CreateInvoiceCommand = {
13
+ customerId: CustomerId;
14
+ amount: number;
15
+ };
16
+
17
+ export type InvoiceSummary = {
18
+ count: number;
19
+ totalAmount: number;
20
+ };