Code generator開発記(Part 2)
!
この記事は2023年7月3日に執筆されたものです。
こんにちは。Inflabのフロントエンドエンジニア、Lucasです。
前回の記事では、code generatorの必要性と、自前で実装するに至るまでの悩みを共有しました。今回は、code generatorをはじめ、schema validator、type guardをどのように実装したのかを簡単にご紹介したいと思います。
最初にお伝えしておくと、これはとてもシンプルなアイデアで、実装に複雑な技術は必要ありません。その前提で読み進めていただければと思います。
1. 目標と必要な機能
まず、前回の記事で立てた目標について改めて考えてみます。私はcode generatorを実装する前に、次のような目標を立てました。
ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。
フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。
フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。
APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。
バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。
バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。
特に最後の目標は、Elixirのdoc testの概念を借りたものです。昨年取り組んだ個人プロジェクトでテストを書いた際の感触が良く、GraphQLがサポートしているようなschemaの整合性検証も、近い形なら実現できるのではないかと期待していました。特に私たちのバックエンドチームはテストを書くことを非常に重視しているので、OpenAPI specificationと実際のAPIの結果をテストする手軽な方法を提供できるだろうと考えました。
以下、OpenAPI specification JSONはOASと略します。
2. 使用したライブラリ
まず、実装に使ったライブラリを紹介します。おそらくほとんどの方がご存じだと思います。
1. 実装
inversify
機能が増えるほど、コードベースは必然的に大きく複雑になるだろうと考えました。また、複数のオプションをサポートする予定だったので、実行時に動く実装も変わりうると見ていました。
そのため機能間の依存関係が複雑になりそうだと考え、悩んだ末に依存性注入(DI)のためのIoCコンテナを探すことにしました。私はinversifyを選びましたが、もう少し軽量に使いたいのであれば、typediという選択肢もあります。
json-schema-to-typescript
事実上、code generatorの中核となるライブラリです。Code generatorのほぼすべての実装は、このライブラリから望む結果を得るためのオブジェクトを作る作業だと言っても過言ではありません。例えば、次のようなJSON schemaがある場合、
{
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
},
"hairColor": {
"enum": ["black", "brown", "blue"],
"type": "string"
}
},
"additionalProperties": false,
"required": ["firstName", "lastName"]
}
次のように型を生成します。
export interface ExampleSchema {
firstName: string;
lastName: string;
/**
- Age in years
*/
age?: number;
hairColor?: 'black' | 'brown' | 'blue';
}
lodash
object-traversal
js-yaml
オプションをYAML形式で受け取るつもりでインストールしました。今思えば、普通にJavaScriptで受け取っても問題なかったと思います。
jsonschema
Code generatorが受け付けられるオプションを検証するために選びました。オプション関連のコードがif ~ elseだらけになるのを避けたかったのです。
2. ビルド、デプロイ
TypeScript
tsup
esbuildベースのビルドツールです。速くて設定が簡単なので選びました。
Release Please
ライブラリではありませんが、このGitHub Actionのおかげで手軽にデプロイできたので紹介します。
コミットメッセージの中にconventional commit形式で書かれたものがあれば、pull requestの作成からデプロイまで自動で行ってくれます。特にchange log、release、git tagなども自動で管理してくれるので、開発者はコードだけに集中できます。
ちなみに、Inflabのフロントエンドチームでは、monorepoで構成されたパッケージはchangesetsで、単独のリポジトリで運用しているパッケージはRelease Pleaseで管理しています。
commitlint
上で説明したRelease Pleaseの動作の仕組み上、開発者のミスを防ぐために導入しました。コードの変更がコミットされる前にコミットメッセージを検査し、conventional commitの形式に従っていなければコミットできないようにします。
コミットメッセージの一貫性を保ち、デプロイのためにコードを変更したのに、コミットメッセージの誤りでパッケージがデプロイされないという問題を防ぐのが狙いです。
3. テスト
Jest
3. 実装の詳細
Code generatorは、大きく3つの段階に分けて実行されます。1つ目は前処理の段階です。正しいオプションが渡されたかを検証し、OASのJSONファイルを読み込みます。続いて、ユーザーが望む前処理ロジック(Pre process)を実行して、読み込んだOAS JSONを加工します。
2つ目の段階では、Taskと呼ばれる作業単位が並列に実行されます。どのタスクを実行するかは、そのタスクのオプションが正しく入力されているかどうかによって決まります。
ReactQueryTaskでは、前処理済みのOAS JSONから生成物に使うオブジェクトを作り、適切な名前と対応付けます。Query、url params、response、request bodyなど、必要と思われる要素をすべて抽出し、型とhooksに変換します。
TypeGuardTaskでは、ReactQueryTaskで作られたインターフェースを検証するtype guard関数を作ります。
SchemaValidatorTaskは、OAS JSONで宣言されたすべてのschemaを検証できるstandaloneのAjvインスタンスを生成します。Ajvについては、後ほど改めて詳しく紹介します。
Taskの並列実行が終わったら、最後の段階として、指定された場所へファイルを書き出します。フローチャートで表すと、次のようになります。
1. オプションの入力を受け取る
ユーザーから希望するオプションをYAML形式で受け取るように構成しました。そのオプションは次のとおりです。
loader: fetch | file [required]
schema: string [required]
output-dir: string [required]
react-query: [optional]
output: string [required]
fetcher: string [required]
version: 3 | 4 [optional] default: 3
validator: [optional]
output: string [required]
typeguard: [optional]
output: string [required]
preprocess: [optional]
filterDeprecated: boolean [optional] default: false
extractEnum: boolean [optional] default: false
apiPaths:
- path:
string [required]
exact:
boolean [optional]
tags:
- string [required]
2. 前処理
1. オプションの検証
YAMLファイルの有無の確認、node argumentsの入力チェック、kebab-caseのオプションのcamelCaseへの変換など、I/Oと副作用はConfigProviderが担当するようにしました。
一方、react-query、preprocessなど各オプションのパース、検証、値の設定は、ConfigLoaderを実装したオブジェクトが担います。各オブジェクトがパースしたオプションは、ConfigProviderに渡されます。こうしたのは、オプションごとの変更の影響を互いに切り離すためです。
例えば、react queryを担当するクラスは次のように実装されています。
import Ajv from 'ajv';
import type ConfigLoader from './ConfigLoader';
import type { JSONSchemaType } from 'ajv';
import { isNil } from 'lodash';
import { InvalidConfigException } from '../utils/exception';
type ReactQueryConfigOptions = {
output: string;
fetcher: string;
version?: number;
removeApiEndpointPrefix?: boolean;
};
type YamlReactQueryConfig = {
reactQuery?: ReactQueryConfigOptions;
};
/**
- 1. react-queryオプションのjson validationロジック
*/
const schema: JSONSchemaType<YamlReactQueryConfig> = {
type: 'object',
properties: {
reactQuery: {
type: 'object',
nullable: true,
properties: {
output: { type: 'string' },
fetcher: { type: 'string' },
version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 },
removeApiEndpointPrefix: { type: 'boolean', nullable: true },
},
required: ['output', 'fetcher'],
},
},
additionalProperties: true,
};
const validate = new Ajv({ useDefaults: true }).compile(schema);
export type ReactQueryConfig = ReactQueryConfigOptions | false;
export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> {
/**
- 2. react-queryのオプションの検証
*/
load(optionCandidate: unknown) {
if (!validate(optionCandidate)) {
throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config');
}
/**
- 3. react-queryのオプションを返す
- 戻り値の型はReactQueryConfigOptions | false
*/
if (isNil(optionCandidate.reactQuery)) {
return false;
}
return optionCandidate.reactQuery;
}
}
ReactQueryConfigLoaderでパースしたオプションは、ConfigProviderのパブリックメソッドとして提供されます。
export class ConfigProviderImpl implements ConfigProvider {
private loader: Loader;
private options: Options;
constructor() {
/**
- 1. yamlファイルの読み込み
*/
const yamlCandidate = this.loadConfig();
const reactQueryConfigLoader = new ReactQueryConfigLoader();
/**
- 2. react-queryのオプションのパースを依頼
*/
const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate);
this.options = {
reactQuery: reactQueryConfig,
};
}
private loadConfig() {
const flags = args.parse(process.argv);
const yamlPath = resolve(process.cwd(), flags.config);
if (typeof yamlPath !== 'string') {
throw new InvalidPathException('ConfigProvider: failed to open yaml config file');
}
const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8')));
return yamlConfig;
}
getOptions(): Options {
return this.options;
}
/**
- 3. オプションが必要なオブジェクトにオプションを提供
*/
taskOption(): TaskOption {
return pick(this.options, ['reactQuery']);
}
}
この構造のおかげで、ConfigLoaderを実装するだけでConfigProviderから使えるようになります。つまり、オプションはいつでも柔軟に追加・変更できます。
2. OpenAPI specificationファイルを受け取る
当初の目標の中に、「ファイルまたはネットワークリクエストでOAS JSONを直接パースし、望む形の結果をファイルとして生成できること。」がありました。これを実現するために、Loaderを実装したFileLoaderとFetchLoaderを作り、inversifyのfactory関数を使って、オプションに応じて使うべきローダーを選択するようにしました。
// inversify.config.ts
const container = new Container();
/**
- 1. IoCコンテナにオブジェクトを登録
*/
container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch');
container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file');
/**
- 2. ファクトリー関数の設定
*/
const loaderFactory = container
.bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory)
.toFactory<Loader, ['file' | 'fetch']>((context) => {
return (named) => {
/**
- 3. どのオブジェクトをどのように読み込むかを明示
*/
return context.container.getNamed<Loader>(TYPES.Loader, named);
};
});
// loader.ts
const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader);
このように実装すると、既存のコードを修正せずに別のLoaderを追加できるというメリットがあります。例えば、databaseからOAS JSONを読み込む必要がある場合は、次のように対応できます。
class DatabaseLoader implements Loader {
// ...
}
container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db');
const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db');
3. Preprocess pipe
Preprocess pipeは、読み込んだOAS JSONをさまざまな条件で加工する工程です。DeprecatedなAPIを除外したり、特定のAPIだけを選択したり、enumの抽出を選択したりといった処理を担当します。preprocessのオプションはいろいろ選べ、こちらもオプションに対応する前処理のロジックが実行時に決まります。PreprocessPipeクラスが、先ほど紹介したfactory関数でPreprocessを実装したクラスを選んで配列に入れ、OAS JSONを順番に渡しながらロジックを実行します。サンプルコードは次のとおりです。
import { flow } from 'lodash';
class PreprocessPipe {
private readonly preprocessOptions: PreprocessConfig;
private readonly initialPreprocess: Preprocess[];
constructor(
@inject(TYPES.PreprocessFactory)
private readonly preprocessFactory: (name: string) => Preprocess,
) {}
exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined {
/**
- 1. オプションで選択されたpreprocessの実装だけを選んで配列に入れる
*/
const preprocess = this.scheduledPreprocess();
const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess));
/**
- 2. preprocessの実装を順番に実行
*/
const result = flow(preprocessorFunctions)(swaggerSpecJSON);
return result;
}
private scheduledPreprocess() {
if (!this.preprocessOptions) {
return [];
}
/**
- enabled = trueのオプションのオブジェクトだけを読み込む
*/
const selectedPreprocess = Object.entries(this.preprocessOptions)
.filter(([, enabled]) => enabled)
.map(([name]) => this.preprocessFactory(name));
return this.initialPreprocess.concat(selectedPreprocess);
}
}
こちらも、オプションを追加・削除しても既存のロジックに影響しないように実装しています。オプションの読み込みと検証、OAS JSONファイルの読み込み、preprocessの実行という前処理の段階は、使う場所によって実行のされ方が変わりうるため、できるだけ柔軟に設計しました。
3. ReactQueryTask
上で説明したとおり、json-schema-to-typescriptというライブラリで望む生成物を得るためにオブジェクトを作る工程です。ある意味で最も重要な部分なので、責務を細かく分けることに気を配りました。基本的な流れは次のとおりです。
Generator役のオブジェクトは、Parser役のオブジェクトに必要な部分のパースを依頼します。Parserが結果を返すと、それぞれの役割に応じて生成物を文字列として組み立てます。
Object Generatingを担当するオブジェクトは、パースされたデータを使って適切な形のオブジェクトを作ります。インターフェースとして生成される名前を指定して、json-schema-to-typescriptに渡します。例えば次のとおりです。
import { compile } from 'json-schema-to-typescript';
const queryString = queryStringParser.generate(oasJson);
/**
- {
- "title": "V1UserListQueryString",
- "type": "object",
- "properties": {
- "firstName": {
- "type": "string"
- },
- "lastName": {
- "type": "string"
- }
- }
- }
*/
const result = await compile(queryString);
/**
- export interface V1UserListQueryString {
- firstName?: string;
- lastName?: string;
- }
*/
react-queryで使うhooksを作るオブジェクトも、同じくパースされたデータから適切な形のhooksの文字列を作ります。Hooksの場合は、template literalを使えば生成結果を簡単に宣言できます。
// template.ts
export default function useMutationTemplate(name: string, method: string) {
const type = ${name}Response;
const optionType = UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>;
return `export function use${name}Mutation<TContext>({
url,
options,
requestOptions,
}: {
url: string;
options?: ${optionType};
requestOptions?: RequestOptions;
}) {
const result = useMutation<${type}, unknown, ${name}Variables, TContext>(
(variables) => fetcher.${method}(url, variables, requestOptions),
options,
)
return { ...result, data: result.data?.data };
}`;
}
// ReactMutationGenerator.ts
const result = useMutationTemplate('V1UserList', 'POST');
このようにgenerate layerで作られた結果は、それぞれstring[]の形でReactQueryTaskに戻されます。ここまでの説明は長くてピンとこないかもしれませんが、次のダイアグラムを見ていただければ、思ったよりシンプルな構造であることがおわかりいただけると思います。
4. TypeGuardTask
1. Type guardとは何か
TypeScriptにおけるtype guardとは、複数の型を取りうる変数について、型の推論範囲を絞り込む(narrowing)ための関数のことです。例えば次のとおりです。
// type guard
const isString = (value: unknown): value is string => typeof value === 'string';
const numOrStringDivide = (value: string | number) => {
if (isString(value)) {
return parseInt(value, 10) / 2; // value -> string
}
return value / 2; // value -> number
};
フロントエンドでtype guardが必要になるのは、上の例のようにprimitive typeを区別したい場合もありますが、API responseの違いを見分けて処理を分岐させなければならない場面が出てくるからです。特に、通常の関数だけでなくcomponentの中でも便利です。例えば、API responseの形に応じてレンダリングするcomponentを切り替えたい場合です。こうした場面で、Inflabのフロントエンドチームはtype guardを積極的に使っています。
const isStudent = (value: Student | Teacher): value is Student => {
return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string';
};
export function Profile() {
// Student | Teacher
const { data } = useProfileQuery();
return (
<div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div>
);
}
2. Type guardの落とし穴
もちろん、GraphQLを使えば__typenameというプロパティを使って簡単に区別できます。しかし、そうでない場合は一般的に、API responseの形を必要な分だけパースする方法で型を区別することになります。上の例で言えば、ある値がオブジェクトで、'grade'というstring型のプロパティを持っていれば、'Student'型とみなすことになります。
これはTypeScriptの構造的型システム(structural type system)に起因するもので、よくまとまったドキュメントが多いので、ここではこれ以上説明しません。
本題に戻ると、Inflabのフロントエンドチームではtype guardを積極的に使っていますが、メリットと同じくらいデメリットもはっきりしていました。それは、type guard自体が誤って宣言されている場合、ロジック上の誤りが表に出てこないということです。代表的なのが、API responseが変わったのにtype guardを変更し忘れたケースです。
上の'Student'型で言えば、API responseが変更されて'grade'というプロパティの型が変わったり、プロパティ自体がなくなったりするケースが考えられます。こうなると、コードレビューをしても見つけにくいだけでなく、コンパイル時には何のエラーも発生しないため、ランタイムで問題が発覚することが多いです。
3. Type guardを自動で作る
こうした理由から、type guardもOAS JSONから自動で作られたらどうだろう? というアイデアが浮かびました。同僚と議論した結果、jsonschemaを使ってOASのschemaを検証できる形にし、ファイルとして出力する機能を実装することに決めました。実装全体の流れは次のとおりです。
まず、OAS JSONをJSON schemaの形に変換し、JSON schemaがサポートしていないプロパティを取り除きます。
import { draft7 } from 'json-schema-migrate';
import { unset } from 'lodash';
function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) {
const componentSwaggerSpecs = swaggerSpec.components.schemas;
/**
- 1. OAS -> json schemaの形に変換
*/
draft7(
toJsonSchema(swaggerSpec.components.schemas, {
strictMode: true,
}),
);
/**
- 2. json schemaでサポートされていないプロパティを削除
*/
removeUnSupportProperty(componentSwaggerSpecs);
return componentSwaggerSpecs;
}
function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) {
const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example'];
traverse(componentSwaggerSpecs, ({ parent, key }) => {
key ??= '';
if (notSupportedType.includes(key)) {
unset(parent, key);
}
});
}
こうして作ったJSON schemaを、template関数を使って文字列にすれば完成です。
const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => {
return [
importTemplate(Object.keys(jsonSchemas)).trim(),
'const validator = new Validator();',
Object.entries(jsonSchemas)
.map(([key, schema]) => typeGuardTemplate(key, schema))
.join('')
.trim(),
].join('\n\n');
};
const importTemplate = (keys: string[]) => {
return `
import { Validator } from 'jsonschema';
import type { ${keys.join(', ')} } from './schema';
`;
};
const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => {
const stringifyJsonScheme = JSON.stringify(jsonSchema);
return `
export const ${is${type}} = (value: unknown): value is ${type} => {
return validator.validate(value, ${stringifyJsonScheme}).valid;
}
`;
};
const schema = convertSwaggerSpecToJSONSchema(oasJson);
const result = defaultTypeGuardTemplate(schema);
まだ完璧に動作するわけではありませんが、問題なく使えるようドッグフーディングを続けています。
5. SchemaValidatorTask
上でもお伝えしたとおり、Elixirのdoc testの考え方を取り入れ、バックエンドエンジニアが自分の宣言したOASと実際のresponseが一致しているかを、integration testの段階で検証できる機能を提供しようとしました。ライブラリにはAjvを選びました。バックエンド側で別途ライブラリをインストールしなくても、standaloneモードで動作するからです。
SchemaValidatorTaskの仕組みは、前の2つのタスクに比べてとてもシンプルです。OASをJSON schemaに変換し、standaloneモードを適用した生成物を返せば完了です。
import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema';
import Ajv from 'ajv';
import standaloneCode from 'ajv/dist/standalone';
const schema = toJsonSchema(oasJson);
const ajv = new Ajv({
schemas,
code: {
source: true,
optimize: true,
},
});
const result = standaloneCode(ajv);
6. 生成物の出力
各タスクでは、受け取ったstring配列から一定の基準で重複を取り除きます。ここまで問題がなければ、オプションで指定されたパスに結果を出力します。
4. 生成物の形
次のようなOAS JSONを受け取ったと仮定すると、各タスクの実行結果は以下のとおりです。
OAS.json{
"openapi": "3.0.0",
"paths": {
"/api/v1/category": {
"get": {
"operationId": "CategoryB2cController_findAll",
"summary": "すべてのカテゴリー一覧",
"description": "すべてのカテゴリー一覧を取得する。",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"properties": {
"statusCode": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CategoryAllResponse"
}
}
}
}
}
}
},
"tags": ["カテゴリーenum API"]
}
}
},
"components": {
"schemas": {
"CodeName": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["code", "name"]
},
"JobGroupCodeName": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"name": {
"type": "string"
},
"jobs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CodeName"
}
}
},
"required": ["code", "name", "jobs"]
},
"CategoryAllResponse": {
"type": "object",
"properties": {
"jobGroup": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JobGroupCodeName"
}
},
"jobSeekerStatus": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CodeName"
}
}
},
"required": ["jobSeekerStatus"]
}
}
}
}
ReactQueryTaskimport { useQuery, useMutation } from '@tanstack/react-query';
import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query';
import fetcher from './Fetcher';
import type { RequestOptions } from './Fetcher';
export type Maybe<T> = T | null;
export type OneOf<T> = T extends unknown[]
? T[number]
: T extends readonly unknown[]
? T[number]
: T;
export type Exact<T extends { [key: string]: unknown }> = {
[K in keyof T]-?: Exclude<T[K], undefined>;
};
export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U>
? U
: T extends { [key: string]: infer U }
? U
: never;
export const enum ResponseStatus {
OK = 'OK',
SERVER_ERROR = 'SERVER_ERROR',
DUPLICATE = 'DUPLICATE',
NOT_FOUND = 'NOT_FOUND',
BAD_REQUEST = 'BAD_REQUEST',
BAD_PARAMETER = 'BAD_PARAMETER',
CONFLICT = 'CONFLICT',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
}
export type Success<T = unknown> = {
statusCode: ResponseStatus.OK;
message: '';
data: T;
};
export type Error = {
statusCode: Omit<keyof typeof ResponseStatus, 'OK'>;
message: string;
data: Maybe<''>;
};
/**
- @typeParam T - Response data type
*/
export type ApiResponse<T = unknown> = Success<T>;
export const API_ENDPOINT = {
V1_CATEGORY: '/api/v1/category',
} as const;
export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) =>
fetcher.get<V1CategoryResponse>(url, requestOptions);
export function useV1CategoryQuery({
queryKey,
url,
options,
requestOptions,
}: {
queryKey: QueryKey;
url: string;
options?: Omit<
UseQueryOptions<V1CategoryResponse, unknown>,
'queryKey' | 'queryFn' | 'initialData'
> & {
initialData?: () => undefined;
};
requestOptions?: RequestOptions;
}) {
const result = useQuery<V1CategoryResponse, unknown>(
queryKey,
() => fetchV1Category(url, requestOptions),
options,
);
return { ...result, data: result.data?.data };
}
/**
- created by ResponseSchemaGenerator
*/
export interface V1Category {
data: Maybe<CategoryAllResponse>;
}
export interface CategoryAllResponse {
jobGroup: Maybe<JobGroupCodeName[]>;
jobSeekerStatus: CodeName[];
}
export interface JobGroupCodeName {
code: string;
name: string;
jobs: CodeName[];
}
export interface CodeName {
code: string;
name: string;
}
export type V1CategoryResponse = ApiResponse<V1Category['data']>;
TypeGuardTaskimport { Validator } from 'jsonschema';
import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema';
const validator = new Validator();
export const isCodeName = (value: unknown): value is CodeName => {
return validator.validate(value, {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
}).valid;
};
export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => {
return validator.validate(value, {
type: 'object',
properties: {
code: { type: 'string' },
name: { type: 'string' },
jobs: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['code', 'name', 'jobs'],
}).valid;
};
export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => {
return validator.validate(value, {
type: 'object',
properties: {
jobGroup: {
type: 'array',
items: {
type: 'object',
properties: {
code: { type: 'string' },
name: { type: 'string' },
jobs: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['code', 'name', 'jobs'],
},
},
jobSeekerStatus: {
type: 'array',
items: {
type: 'object',
properties: { code: { type: 'string' }, name: { type: 'string' } },
required: ['code', 'name'],
},
},
},
required: ['jobSeekerStatus'],
}).valid;
};
SchemaValidatorTaskの結果は1つの複雑なファイルになるため、ここでは紹介を割愛します。
こうして作られた生成物は、フロントエンドのコードで次のように使えます。
import { useV1CategoryQuery, API_ENDPOINT } from './schema';
import { isJobGroupCodeName } from './guard';
const retrieveJobGroupCodeNameJobs = (value: unknown) => {
if (isJobGroupCodeName(value)) {
return value.jobs;
}
return [];
};
export function A() {
const { data } = useV1CategoryQuery({
url: API_ENDPOINT.V1_CATEGORY,
queryKey: [API_ENDPOINT.V1_CATEGORY],
});
return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>;
}
バックエンドのテストコードでは、schema validatorを次のように使えます。
import validator from './validator';
describe('categoryAllResponse', () => {
it('categoryAllResponseのOASと実際の結果は一致しなければならない。', async () => {
const response = await controller.categoryAllResponse();
const result = validator.categoryAllResponse(result);
expect(result).toBe(true);
});
});
5. まとめ
最初にお伝えしたとおり、一見すると工程は複雑に見えますが、まったくそんなことはありません。むしろ実装中に悩んだのは、責務をどう適切に分担するかと、SonarQubeのduplicate line code smellを避けるためにコードの重複をどう抑えるかでした。そのため、コード全体を通して次のような選択をしました。
各オブジェクトの責務を細かく分離するよう努めました。
各オブジェクトの横断的関心事はServiceというlayerに分け、必要なオブジェクトが必要な分だけ参照するように実装しました。
実行時に動くロジックを切り替える必要がある場合は、Strategyパターンを積極的に使いました。
同じロジックで小さな部分だけを変える必要がある場合は、Template Methodパターンを使いましたが、継承が1段階を超えないように実装しました。
実装した構造を簡単に表すと、次のようになります。
実装の紹介は以上です。
次回の記事では、code generatorを導入してから働き方がどのように変わったのかをご紹介します。
This is a real gem:
googleapis/release-please
Thanks @seuros!!
2 likes · 87 views
Conventional Commits に切り替えて、本当に変わったこと
最初期の履歴には、nuxt.js Learning、bug fixed、almost finishedというコミットが残っています。当時はそれで困らないと思っていました。数か月後、自分で変更理由を調べるまでは。
それからConventional Commitsを個人のルールとして使い始めました。2年分の履歴を集計すると、type付きコミットの比率は初期の約2%から、後期プロジェクト全体で約65%まで増えています。ただし、自動リリースやCHANGELOG生成まで整備したわけではありません。この記事では、改善した部分と、今も雑なまま残っている部分の両方を書きます。
半年後の自分が履歴を読めなかった
Conventional Commitsを使って得た一番直接的なメリットは、CHANGELOGの自動生成ではなく、半年後の自分でもgit logを読めるようになったことでした。
働き始めた頃の私は、commit message を思いついたまま書いていました。2 年分のデータを後から集計してみると、いくつかの比率がなかなか目を引きます。
以下の「後期」は複数プロジェクトを合算した値です。単一プロジェクトでは24~90%程度のばらつきがあり、どのリポジトリも一様に改善したわけではありません。
観点
初期(最初のいくつかのプロジェクト)
後期(後期プロジェクト合計)
semantic prefix 付き(feat/fix/refactor)
約 2%
約 65%
body 付き(少し説明を足す)
約 5%
約 60%(プロジェクト間で 24〜90%)
複数行 message
約 2%
約 60%
平均 message 長
約 7 語
約 9〜10 語
数字だけを見るときれいですが、実際には定着まで時間がかかり、今も規約外のコミットが残っています。
背景:当時の commit message はどれだけ雑だったか
入社後に最初に引き継いだ業務プロジェクトは、ある製造業のお客さま向けの倉庫管理 UI でした。当時の私は Nuxt を触り始めたばかりで、commit は「ひと区切りついたら一筆書く」という状態でした。
当時のメッセージをいくつか拾ってみます(固有名詞は匿名化していますが、雰囲気はこのままです)。
nuxt.js Learning
try to show pics in the xxxPage
bug fixed
almost finished
bug repair and change some code format
except the double floor task creation func, almost finish
bug fixed ってどの bug を直したのか? almost finished は何が 90% 完了したのか? nuxt.js Learning の行に至っては、フレームワークを触り始めたばかりの頃の「日記」です。当時はプロジェクトが動いて、ちゃんと納品できていれば、message がこんな調子でも誰も何も言いませんでした。
問題はそのあとです。数か月後に「この状態切り替えのロジックはどの commit で変えたんだっけ」と調べに戻ると、git log --oneline を bug fixed / almost finished と一行ずつたどっていくしかなく、まったく当てになりませんでした。最後は git log -p で diff を一区切りずつ読む羽目になり、コードを読み直すより遅いくらいでした。
その頃の私はこれを「問題」だとは思っておらず、履歴を調べるのが面倒くさいな、と諦めていただけでした。
転機:自分で立ち上げたあのプロジェクトから
転機は 2025 年の半ばでした。私が初めてゼロから自分でプロジェクトを立ち上げたときです。それまではずっと他人のコードを引き継いで上に足していく形でしたが、このときはリポジトリ全体を自分で組みました。
不思議なもので、最初の commit から自分で作り始めたからこそ「今回はちゃんと書きたい」という気持ちが湧きました。Conventional Commits の規約を真面目に読んだのもこの頃です。中身は拍子抜けするほどシンプルでした。
<type>(<scope>): <summary>
<body 任意>
type はよく使うものが数種類だけ。feat(機能追加)/ fix(バグ修正)/ refactor(リファクタ)/ chore(雑務)/ docs(ドキュメント)。私はツールは一切入れず、そのプロジェクトから commit のたびにまず「これはどの type か」を考えてから summary を書くようにしました。「そのプロジェクトから」と書いて「初日からできた」と書かないのには理由があります。そのプロジェクトでいうと、init から、毎回ちゃんと feat:/fix: 接頭辞を付けられる状態になるまで、習慣として定着するのに 2 か月近くかかりました。規約はスイッチではなく、じわじわ育っていくものです。
ここは正直に言っておきます。私は commitlint / husky のような機械的にチェックするツールは入れていないし、チームで強制されていたわけでもありません。 最初から最後まで、自分で自分に課した一本の規律です。なので以下のデータは「一人で続けた結果」であって、「CI で弾かれてコミットできない」結果ではありません。この 2 つの重みの違いは、自分でも分かっています。
before / after:変化は測れる
初期プロジェクトと後期プロジェクトの commit を並べて比べると、差ははっきり出ます。
初期は上のbug fixedの世界でした。後期はこうなりました(同じく固有名詞は匿名化しています)。
feat: enhance area selection, fix status bugs, and add charging task support
- Allow locked areas to be clicked and show selected highlight
- Fix toggle status bug: use level-based lookup instead of index-based
- Add helper to disable areas containing equipment racks
fix: Correct reachability logic for area accessibility check
refactor: extract <component> logic into composables
どこが違うのか。
その commit が何をするものか一目で分かる —— feat か fix か、接頭辞をさっと見るだけで足ります
body に「何を / なぜ」変えたかが書いてある —— たとえば「index ベースの参照を level ベースに置き換えた」と書いてあれば、半年後の自分でも当時のハマりどころを思い出せます
git log --oneline が読めるリストになる —— almost finished の羅列ではなく
データで見ると(ここでの後期の数字はいずれも後期プロジェクト合計で、単一リポジトリではありません)、semantic prefix は約 2% から約 65% へ(おおよそ 30 倍)、body 付きは 5% から約 60% へ(プロジェクトによって 24% から 90% までと均一ではありません)、複数行 message は 2% から 60% へ、平均長は約 7 語から約 9〜10 語へ。はっきり指せるマイルストーンの一つが、2025 年 9 月の feat: Add automated status monitoring です。これはそのプロジェクトの init から 2 か月以上経ってからのことで、規約がじわじわ育つものだということを改めて示しています。
本当に変わったこと
数字は表面です。日常レベルで本当に変わったのは、この 3 つだと思っています。
第一に、git log が使えるものになりました。 今は「この機能はどの commit で入ったか」を調べるとき、git log --oneline --grep=feat を一発、あるいは接頭辞をさっと眺めるだけで特定できます。以前の git log -p で diff を一区切りずつ読む苦行は、ほぼなくなりました。
第二に、変更を見直す単位が揃いました。 1コミットに1つのtypeを付けると、未来の自分も「これは機能追加か、不具合修正か」を意識してdiffを読めます。bodyに理由を残したコミットは、コードだけを読み直すより判断が速くなりました。
第三に、自分の作業ログになりました。 これは思わぬ収穫でした。body 付きの commit を書き続けると、git 履歴そのものが「この 2 年で自分が何をやって、どんな落とし穴を踏んだか」の記録になります。職務経歴書を書くときや成長を振り返るとき、この履歴は記憶よりずっと当てになります。この記事のデータも、実はこの履歴から拾い出したものです。
正直な部分:できなかったことのほうが、たぶん語る価値がある
ここで記事が終わったら、ただの「規約を入れたら最高」という宣伝文になってしまいます。でも実際はそんなに円満ではありません。私ができなかったいくつかの点こそ、この記事で残したい本題です。
100% 規約化には至っていません。 後期のリポジトリですら、HEAD にはまだこんな message が残っています。
constrained the retrieval of park(only type 0 and 6)
接頭辞なし、全部小文字、思いつくまま。傾向としては、機能追加やバグ修正のような「ちゃんとした」変更はきちんと feat: / fix: を付けるけれど、ちょっとした一時的な調整になると手を抜く、という感じです。あとで気づいたことが一つあります。100% 徹底する必要は実はないし、難しいということです。重要で、あとから振り返って調べる変更を規約に沿って書ければ十分で、一行残らず完璧にと突き詰めるのは、別の種類の消耗です。
1 つの commit に複数の type が混ざっていることがよくあります。 上の feat: ... fix status bugs, and add charging task support は、厳密には Conventional Commits の「1 commit に 1 つのこと」という理想に反しています。feat であると同時に fix でもあるからです。現実には作業中の変更がいくつもの関心事をまたぐことが多く、そこまできれいに分けるには commit の粒度にもっと気を配る必要があります。これはまだ練習中です。
最大の弱点:release / CHANGELOG の自動化はまったく手を付けていません。 Conventional Commits の最大の売りは、それをもとに CHANGELOG を自動生成し、SemVer のバージョンを自動で上げられることです。でも数えてみたら —— 9 プロジェクトを通じて git tag 0 個、release 0 個でした。つまり私は規約を入れたのに、その恩恵をまだ一口も食べていないのです。接頭辞をどれだけ整えても、tag と自動化がなければ「log が読める」という一層の価値しか発揮できず、残りの大半のポテンシャルは無駄になっています。
それは最後まで個人の規律であって、チームの規約ではありませんでした。 CI 上で commitlint が弾いてくれるわけではなく、すべて自覚頼みです。良いところは導入コストがゼロなこと、悪いところは —— 気を抜くと崩れること(上の接頭辞なしの行がその証拠です)。
次に直したいこと
Conventional Commitsを個人のルールとして使い始めたことで、git logは少なくとも「後から検索できる記録」になりました。一方、これは運用の入口にすぎません。今も次の課題が残っています。
接頭辞は付けるようになりましたが、自動化はまだつないでいません。残っている課題は二つです。
commitlint + huskyを入れる。 commit-msgフックで規約に合わないメッセージを止め、人の注意力だけに頼らないようにする。
tag + 自動CHANGELOGをつなぐ。 release-pleaseなどを使い、コミット履歴をリリース記録に反映する。
次に同じ規模のプロジェクトを始めるなら、初日からcommitlintを入れます。私の場合、ルールを覚えるまでに約2か月かかりました。人の注意力だけに任せるより、最初から機械に止めてもらうほうが早かったはずです。それでも、bug fixedしか残っていなかった頃と比べれば、接頭辞を付ける習慣だけでも履歴の使い勝手は大きく変わりました。
参考リンク
Conventional Commits(公式仕様)
commitlint
release-please
standard-version
8 Paths Your Coding Agent Should Never Edit, and the Deny Rule for Each
- Book: Event-Driven Architecture Pocket Guide
- The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
The diff for a one-test fix touches four files: the test, package-lock.json, a migration that ran in production months ago, and the CI workflow, which the agent "tidied". Each change looks reasonable in isolation. Each one can cost you a day to unwind.
Reading every diff catches some of this; a rule that refuses the edit up front catches more. Below are eight paths, the failure an agent edit causes in each, and how to block it in Claude Code, Codex, Antigravity CLI and Gemini CLI, plus a server-side backstop for agents that have no deny syntax at all.
"Never edit" means never with the agent's own file tools. Lockfiles and generated code still change, through the package manager or the generator. That route stays open on purpose.
The eight paths
One warning first. Claude Code's docs say Read and Edit deny rules cover its built-in file tools, file commands it recognises in Bash such as cat, sed and tee, and redirection targets. They don't cover "arbitrary subprocesses", like a Python or Node script that opens files itself. Treat these rules as a filter on the obvious path. The OS-level wall is the sandbox.
1. Lockfiles
npm ci "will exit with an error" when the lockfile and package.json disagree, according to the npm docs, so a hand-patched lockfile fails your next CI run. The worse case passes CI: a lockfile entry can resolve a package to a different tarball, and Liran Tal showed how easily that hides in a diff, asking "who really reviews thousands of lines of character changes?" (Snyk). His lockfile-lint checks the sources in CI.
Rule: Edit(/package-lock.json), plus pnpm-lock.yaml, yarn.lock, uv.lock or composer.lock for whatever you use. The agent can still run npm install <pkg> and let npm write the file.
2. Migrations that already ran
Flyway stores a CRC32 checksum for each migration it runs, and validation fails when "differences in migration names, types or checksums are found." Edit an applied file and the next migrate stops, because validateOnMigrate defaults to true (Flyway docs). Rails records each migration it runs by its version number, so after you edit an applied file it "thinks it has already run the migration and so will do nothing" (Rails guides). A fresh database gets the edited version, production keeps the old one, and nothing reports the difference.
Rule: ask, not deny, on Edit(/db/migrate/**). The agent still needs to add new migrations, so every write prompts you and you reject the ones that touch old files. The CI check in the backstop section below enforces it.
3. CI workflows
A workflow runs with your repository's secrets. In September 2025, a commit titled "Add Github Actions Security workflow" added a workflow that posted secrets to an attacker's server: 3,325 secrets from 817 repositories (GitGuardian). That was an attacker, not an agent. An agent "fixing CI" writes to the same file, with the same reach.
Rule: Edit(/.github/workflows/**), and Edit(/.github/actions/**) if you keep composite actions there.
4. Generated code
The next generator run overwrites any hand edit. Go marks generated files with a line matching ^// Code generated .* DO NOT EDIT\.$ (cmd/go). On GitHub, paths marked linguist-generated in .gitattributes are "hidden by default in diffs" (GitHub docs), so a hand edit there is easy to scroll past in review.
Rule: Edit(/src/gen/**) (use your real output folder). The agent edits the .proto or OpenAPI source and runs the generator. The generator is a subprocess, so the deny rule doesn't stop it, which is what you want here.
5. Vendored dependencies
go mod vendor "resets the main module's vendor directory" (cmd/go), so a patch inside vendor/ disappears the next time anyone runs it. Until then, the go command only checks that vendor/modules.txt matches the versions in go.mod (Go modules reference). The reference describes no check of the vendored files themselves, so a patched copy can build and ship without a warning. The durable fix is a fork and a replace directive.
Rule: Edit(/vendor/**), and third_party/ if you have one.
6. CODEOWNERS
CODEOWNERS decides who is asked to review what, and with branch protection, who must approve. GitHub reads it from the base branch of a pull request, so an edit doesn't change the reviewers of its own PR. Once it merges, though, it changes who reviews everything after. An agent "removing stale owners" can drop the only required reviewer for your payments folder.
Rule: Edit(/.github/CODEOWNERS), or the root or docs/ copy if that is where yours lives.
7. Released changelog entries
release-please writes CHANGELOG.md from Conventional Commits when you merge its Release PR. To fix a note, you add a BEGIN_COMMIT_OVERRIDE section to the merged PR's body (squash merges only), and release-please uses it on its next run, so it fixes notes for the coming release. An agent rewriting a released entry by hand makes the changelog disagree with the tag that shipped.
Rule: Edit(/CHANGELOG.md). If you keep a hand-written "Unreleased" section, use ask instead.
8. Secrets files
Reading .env sends its values to the model with the rest of the context. In Claude Code, a Read deny rule also blocks Edit and Write on the same path, including creating a new file there. NotebookEdit isn't covered, so add an Edit rule if you use notebooks (docs).
Rule: Read(.env), Read(.env.*) and Read(*.pem). Bare filenames match at any depth, and a later Read(!.env.example) carves your example file back out.
The whole block for Claude Code
Commit this as .claude/settings.json. In project settings, a leading / anchors at the session's working directory, and in a worktree session Edit(/src/**) matches that worktree's own src/ (docs). Rules run deny, then ask, then allow, and an allow rule can't carve an exception out of a deny.
{
"permissions": {
"deny": [
"Edit(/package-lock.json)",
"Edit(/.github/workflows/**)",
"Edit(/src/gen/**)",
"Edit(/vendor/**)",
"Edit(/.github/CODEOWNERS)",
"Edit(/CHANGELOG.md)",
"Read(.env)",
"Read(.env.*)",
"Read(!.env.example)",
"Read(*.pem)"
],
"ask": [
"Edit(/db/migrate/**)"
]
}
}
Write rules as Edit(...), not Write(...). The docs say Claude Code accepts a Write(path) rule but never consults it, and warns at startup.
Codex, Antigravity CLI and Gemini CLI
Codex has permission profiles in config.toml. They are marked beta. Extending :workspace already keeps .git, .agents, .codex and .aws read-only, and a path set to read stays readable but not writable (docs):
default_permissions = "guarded"
[permissions.guarded]
extends = ":workspace"
[permissions.guarded.filesystem.":workspace_roots"]
".github" = "read"
"vendor" = "read"
"src/gen" = "read"
"**/.env" = "deny"
"**/.env.*" = "deny"
Some limits. The docs only show globs as deny rules and directories as read entries, so this profile leaves single files like lockfiles to the backstop. The .env.* glob also hides .env.example, and an unbounded ** deny may need glob_scan_max_depth. The docs also describe profiles as the boundary for sandboxed commands, enforced by the OS sandbox, without spelling out Codex's own patch edits. Try a throwaway edit before you trust it. Use either profiles or sandbox_mode, not both.
Antigravity CLI reads action(target) rules from one global file, ~/.gemini/antigravity-cli/settings.json. Deny beats ask beats allow, and paths are relative to the workspace roots (docs). The CLI docs describe no per-repo file, so these rules apply to every project you open:
{
"permissions": {
"deny": [
"write_file(.github/)",
"write_file(vendor/)",
"write_file(src/gen/)",
"write_file(package-lock.json)",
"write_file(CHANGELOG.md)",
"read_file(.env)"
]
}
}
Gemini CLI was replaced by Antigravity CLI for unpaid-tier and Google One users on June 18, 2026 (Gemini CLI docs). If you still run it, its policy engine matches argsPattern as a regex against the tool's arguments serialised to JSON. Workspace policy files are disabled, so this goes in ~/.gemini/policies/agent-paths.toml. The edit tools are write_file and replace, and both take file_path (tools):
[[rule]]
toolName = ["write_file", "replace"]
argsPattern = '"file_path":"[^"]*\.github/workflows/'
decision = "deny"
priority = 900
The backstop for agents with no deny syntax
Some agents have no path rules, and every local rule can be edited by someone on the machine. The server doesn't care which tool wrote the commit.
Own the risky paths, including the CODEOWNERS file itself, then turn on "Require review from Code Owners" in branch protection. Without that setting, owners are requested but approval isn't enforced (GitHub docs):
# .github/CODEOWNERS
/.github/ @acme/platform
/db/migrate/ @acme/data
/vendor/ @acme/platform
/CHANGELOG.md @acme/release
Repository rulesets add two more. "Restrict file paths" blocks pushes of commits that change matching paths, which suits .env files nobody should push; push rulesets work in private and internal repositories (About rulesets). "Required reviewers" asks specific teams to approve changes to file patterns, in organisation repos. On GitHub Enterprise Server, an enforced pre-receive hook rejects any push where its script exits non-zero.
For migrations, fail the PR when an existing file changes. --diff-filter=MDR lists modified, deleted and renamed files and skips added ones:
changed=$(git diff --name-only --diff-filter=MDR \
origin/main...HEAD -- db/migrate)
if [ -n "$changed" ]; then
echo "Applied migrations changed:"
echo "$changed"
exit 1
fi
Where a slipped edit shows up in Hermes
I build Hermes IDE, so weigh this with that in mind. It runs Claude Code, Codex, Antigravity CLI and other agents side by side, each task on its own hermes/ branch and worktree. A committed .claude/settings.json is a tracked file, so it is already in every task's worktree.
Rules miss things: a script the agent runs can still write any of these files. The Review Desk (⌘G, Ctrl+Shift+G on Windows and Linux) shows an agent's changes by turn or by file, with risk flags. Don't count on a flag for every path in this list: open the by-file view and check the eight paths yourself.
!The Hermes Review Desk in By turn view, listing two turns and showing turn 1's diff of src/db.ts with the changed-files list and a drafted commit message
The Review Desk (⌘G) in By turn view for a Claude Code task in orders-api: two turns, turn 1's diff of src/db.ts, the changed-files list, and a commit message drafted from the turns.
Every agent turn is saved, so when a turn touched one of those paths, you can diff that turn or restore it, with undo. A line comment goes back to the agent that wrote the line. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux: download it or read the source on GitHub.
---
If this was useful
Two of these rules, migrations and changelogs, rest on one idea: once history has shipped, you append to it and never rewrite it. That idea runs through the Event-Driven Architecture Pocket Guide, where event logs, outbox tables and sagas all depend on never editing what already happened. It's one of 8 short books in the Pocket Guides series, covering agents, RAG, prompts, observability, events, databases and system design.

Compiling Inline Event Handlers (onclick) for Strict CSP in Astro (astro-strict-csp v0.2.0)
Right after publishing v0.1.3 of astro-strict-csp to handle Shiki inline styles and <head> theme scripts, I remembered another annoying CSP workaround sitting in my own website's codebase: inline event attributes.
When you enforce a strict Content Security Policy (script-src 'self' with zero 'unsafe-inline'), browsers don't just block <script> blocks—they also ruthlessly block every inline DOM event attribute like onclick, onmouseenter, or onsubmit.
If you want a simple <button onclick="window.print()"> in a template or markdown file, strict CSP forces you to give it an ID, open a separate <script> tag, query the selector, and attach an addEventListener. It’s tedious boilerplate that ruins the joy of writing simple HTML.
Since the whole point of building astro-strict-csp was to stop thinking about security workarounds across my projects, I moved this fix into the package and shipped v0.2.0. Now I can write lazy inline onclick attributes whenever I want and let the compiler turn them into strict-CSP-compliant, SRI-hashed bundles at build time.
---
1. The Build-Time Transformation: Zero 'unsafe-inline' Without Rewriting HTML
Instead of forcing you to refactor components or weaken your policy with 'unsafe-hashes', astro-strict-csp extracts inline event handlers during astro:build:done and rewrites them into data-csp-on-* attributes.
What you write in your Astro template or Markdown:
<button onclick="window.print()" class="btn">Print Recipe</button>
<a href="#" onclick="copyLink(this); return false;">Share</a>
What actually ships in production HTML:
<button data-csp-on-click="evt-a1b2c3" class="btn">Print Recipe</button>
<a href="#" data-csp-on-click="evt-d4e5f6">Share</a>
<!-- Automatically hashed by the SHA-384 SRI pipeline -->
<script type="module" src="/_astro/events-8f92a1.js" integrity="sha384-..." crossorigin="anonymous"></script>
Because the event extraction step runs right before the Subresource Integrity (SRI) stage in our single-pass in-memory pipeline, the generated /_astro/events-[hash].js bundle automatically gets a cryptographic sha384 integrity hash and is whitelisted in your Content Security Policy.
---
2. The Runtime Engine: Preserving Native Browser Semantics
Extracting the string inside onclick="..." into an external file is harder than it looks because browsers give inline event attributes very specific execution semantics that standard addEventListener callbacks don't have out of the box:
1. this Binding: Inside onclick="copyLink(this)", this refers to the DOM element itself.
2. The Implicit event Argument: Inline handlers have access to a local event variable without declaring it.
3. return false Behavior: Returning false from an inline handler automatically calls event.preventDefault().
To mirror native browser behavior without attaching hundreds of individual listeners across the DOM, astro-strict-csp generates a single delegated dispatcher:
// Generated in /_astro/events-[hash].js
const HANDLERS = {
'evt-a1b2c3': function(event) { window.print(); },
'evt-d4e5f6': function(event) { copyLink(this); return false; }
};
['click'].forEach(evt => {
document.addEventListener(evt, e => {
for (const el of (e.composedPath ? e.composedPath() : [])) {
if (el.getAttribute) {
const id = el.getAttribute('data-csp-on-' + evt);
if (id && HANDLERS[id]) {
// Mirrors native inline event semantics:
// 1. `this` is bound to the element via .call(el, e)
// 2. `event` is passed as the parameter
// 3. `return false` prevents default browser action
if (HANDLERS[id].call(el, e) === false) {
e.preventDefault();
}
break;
}
}
}
}, { capture: true });
});
---
3. The { capture: true } Trick for Non-Bubbling Events
Most naive event delegation scripts attach a normal bubbling listener to document and check e.target. That works fine for click or keydown, but it fails completely on non-bubbling events like:
- focus and blur
- mouseenter and mouseleave
- load and error
- scroll
Because those events do not bubble up the DOM tree, a normal listener on document never hears them.
However, per the W3C UI Events specification, even non-bubbling events still travel down the DOM tree during the Capture Phase before reaching their target. By registering the root listener with { capture: true } and walking e.composedPath(), our single delegated listener cleanly intercepts onfocus, onblur, and onmouseenter attributes across the entire document—including inside open Shadow DOM trees.
And just like the CSS extractor in v0.1.0:
- Global Deduplication: If 50 buttons across 20 pages use onclick="window.print()", the handler is hashed and emitted only once in HANDLERS.
- Region Masking: Code blocks (<code>, <pre>), <textarea>, <script> strings, and HTML comments are masked prior to extraction, so tutorials discussing onclick="..." are never mutated.
- Zero Dependencies: Still 0 external npm dependencies—pure native Node and Web APIs.
---
4. Ditching Custom CI Scripts for Google's release-please
Alongside the v0.2.0 feature work, I cleaned up the repository's release pipeline.
Previously, I used custom GitHub Actions workflows to handle version bumps and npm publishing. Custom release scripts always end up brittle—you forget to update CHANGELOG.md, mismatch a git tag, or fight token permissions.
I replaced the custom CI logic with Google's release-please. Now, because the repo already follows strict Conventional Commits (feat:, fix:, perf:), release-please automatically maintains a living Release PR. Every time a commit lands on main, it updates the semantic version and compiles a categorized changelog. Merging that PR automatically tags the release and publishes the new tarball to npm with zero manual steps.
---
Try It Out
npm install astro-strict-csp@latest
Links:
- npm: npmjs.com
- GitHub: Evgenii-Zinner/astro-strict-csp
If you hit an edge case with a weird inline handler or want to request a feature, drop a bug report or feature request in the GitHub Issues.
PunyGuard: A Chrome Extension That Blocks Every Internationalized Domain
аррӏө.com and apple.com. Put them side by side: can you tell which one is real?
All five letters in the first one are Cyrillic. Even so, Chrome 154's address bar displayed it exactly as-is. It looks identical to apple.com (GIZMODO article).
After reading that article, I built a small Chrome extension called PunyGuard. It blocks access to internationalized domain names (IDNs), all of them, no exceptions. It doesn't judge whether a domain looks similar to anything. Legitimate Japanese domains get blocked too. It may look like a heavy-handed tradeoff, but that tradeoff is the core of the design.
Out of nowhere, a confession: I don't like Japanese domain names. Rather than getting sent to some Japanese-named site where I can't even tell who's behind it, I'd honestly prefer not to be able to reach it at all. That conviction is what the whole design rests on.
So instead of making the machine do fuzzy judgment calls, I gave it a rule it can't get wrong. The machine handles the routine work; I decide what to protect and what to give up. It's a small extension, but the way I think about working with computers and AI shows up directly in both its design decisions and how I built it.
sho7650/punyguard
> ℹ️ If you use unbound (I do too), riorist's post Preventing homograph attacks with Unbound (in Japanese) is worth a look too. Since it works regardless of which browser you use, it's arguably the more secure option.
Miss one character, and the look-alike detection falls apart
Chrome has a mechanism that displays domains containing confusable characters in Punycode. Punycode is a scheme for rewriting domain names that contain non-ASCII characters (ASCII being half-width letters, digits, and symbols), like Japanese or Cyrillic, into an ASCII string starting with xn--. аррӏө.com becomes xn--80a6aa68c8d.com. If a suspicious domain is shown as that jumble of symbols, at least it won't look like apple.com.
So what counts as "confusable"? The basis for that call is a list of look-alike characters. This time, ө (U+04E9) wasn't on the list. Because of that single missing character, five Cyrillic letters sat in the address bar looking exactly like the real thing.
I don't think this is really anyone's mistake so much as a limit of the approach itself. The world has an enormous number of characters, and how they look changes from font to font. Exhaustively listing every "similar-looking character" is a job that never ends. However carefully you grow the list, the next ө will be hiding somewhere.
Human eyes are the same. The assumption that you can tell them apart if you look carefully collapses instantly in the face of a one-character difference. So I decided to stop judging by appearance altogether.
A design that stops judging
All PunyGuard looks at is whether the hostname has a label starting with xn--. That's it. A label is each piece of www.example.com split by .. If even one label starts with xn--, the host is treated as an IDN host and blocked.
However natural an internationalized domain looks on screen in the browser, on the wire it always carries the xn-- prefix. A gatekeeper who judges by faces is vulnerable to look-alikes. A gatekeeper who checks name tags doesn't hesitate, no matter how similar the faces are. I built PunyGuard to be the second kind of gatekeeper.
Before settling on this policy, I considered other paths and left a decision record (ADR). One option was to draw the line by script: let kanji and kana through, block Cyrillic and so on. Its upside is that Japanese domains would get through. But even within the allowed scripts there are confusable characters, so it just moves the problem somewhere else. Another option was to judge by visual similarity, which is the same idea Chrome uses, and exactly the approach that let this one slip through. And as I said, I dislike Japanese domains.
What I agonized over most was an allowlist and a "proceed just this once" button. They'd be convenient. But as long as the warning screen has a button to proceed, there's room for someone to sweet-talk you into pressing it. Phishing is, after all, also the art of getting people to press buttons. I decided to leave them out of v1, and recorded them in the ADR as candidates to add if they ever become truly necessary.
The price I pay instead is clear. Legitimate Japanese domains like 日本語.jp get blocked too. Since I dislike Japanese domains myself, I judged that these false positives don't hurt me. For people who use Japanese domains, it's an inconvenient extension, and I said so near the top of the README. This is a tool for me, first and foremost. It's enough if the people who share my philosophy get it.
Just one blocking rule
The core of the implementation is Chrome's declarativeNetRequest (DNR from here on). An extension registers rules with Chrome along the lines of "treat requests matching this condition like this," and from then on Chrome applies them itself. The extension doesn't need to peek at every page the user visits.
PunyGuard registers just one rule, and its condition is this regex:
^https?://(?:[^/?#]*[.@])?xn--
Reading from the start of the URL, it matches if any label in the hostname starts with xn--. It also blocks when the top-level domain is an IDN (e.g. example.xn--q9jyb4c). It doesn't match hosts that merely have xn-- in the middle of a label (axn--b.com), or xn-- appearing in the path or query. It applies to two things, top-level page navigations and pages embedded in another page (iframes), and doesn't affect loading of resources like images or scripts.
DNR converts the hostname to Punycode before matching. So even if you type аррӏө.com into the address bar in Unicode, the same rule catches it. Clicking a link, typing in the address bar, a server-side redirect, a link opened from another app: whatever the path, the result is the same.
The requirement I first wrote down was "if a URL contains anything other than English ASCII, refuse the click." While working out the plan, I realized I couldn't build it as written, and revised it in two directions.
First, I narrowed the check to the hostname (FQDN) only. If paths were included, pages like ja.wikipedia.org/wiki/東京 would get blocked too, which would make it unusable. Besides, by the time a request goes out, Japanese in the path has already been converted into an alphanumeric sequence like %E6%9D%B1, so you can't even ask "does it contain non-ASCII?"
Second, I widened it to block paths other than clicks. Behind the wording of the requirement was the actual goal: "don't let me get to a fake site." Rather than building it to the letter, I chose to build it to that goal.
Why ask for one more permission?
After blocking, what do you show the user? This is where I made one big decision.
DNR has block, which simply stops the request, and redirect, which sends it somewhere else. With block, the permissions you request stay minimal. But what the user sees is Chrome's standard error screen, ERR_BLOCKED_BY_CLIENT. That gives no way to tell whether the network is acting up or an extension blocked it. Faced with an unexplained wall, some people would get confused and just remove the extension.
So I went with redirect, sending the user to a warning page bundled with the extension. The catch is that redirect requires access to all sites (<all_urls>). Minimal permissions versus telling the user why it was blocked: I weighed the two and chose the latter.
Access to all sites is on the larger end of extension permissions. That's exactly why I keep everything else doing as little as possible. There isn't a single program that touches the content of the page you're viewing (a content script). It makes no external requests and stores nothing. What it requests, what it doesn't do, and why are all written plainly in the README. An extension that can't explain why it needs such broad permissions deserves not to be trusted.
The redirect URL includes the extension's ID, and the ID varies by environment. So instead of hard-coding the rule in a file, the extension builds and registers it when it starts up. It re-registers every time: on install and update, and on browser startup. Dynamically registered rules are supposed to persist across restarts, but I decided not to rely on that "supposed to."
The warning page trusts nothing
The warning page shows the original URL, the hostname in both Unicode and Punycode, and a list of the non-ASCII characters. For аррӏө.com, you can see that ө is Cyrillic U+04E9. It's a screen that reveals, character by character, an identity you can't see by looking. The display language follows the browser: Japanese for Chrome in Japanese, English otherwise.
The original URL is passed in the fragment (the part after #) of the redirect URL. The thing I was careful about here was not trusting any input to this page at all. The warning page can be opened from anywhere on the web with any fragment attached. You have to build it assuming a malicious string will show up.
All output to the screen goes through textContent, so there's no path for a string to be interpreted as HTML. If a URL comes in that can't be parsed, it shows only generic text, with no host information. The original URL isn't turned into a link, and there's no button to copy it either. I decided from the start not to build anything you could press by accident.
On a smaller note, I tripped over the string :~:. Chrome strips the #:~:text= syntax, used to jump to a specific passage on a page, from the URL the page can read (location.hash). If the original URL happens to contain :~:, everything after it disappears. This was caught in a review by Codex, and I fixed it to read the URL from the navigation history, before the stripping happens.
There are two buttons. The primary one, "Close this tab," is always shown. It's the action that best fits the goal of getting away from a suspicious link. The secondary one, "Go back to the previous page," is shown only when there's history. When the warning page appears inside an iframe, neither is shown. Closing the parent page's tab from inside an iframe goes beyond what the user intended.
I thought about the buttons a lot. I wanted to get away with a single button, "go back if you can, close if you can't," but browsers have no way to accurately check whether there's cross-site history behind you. I also considered going back, waiting a bit, and closing the tab if the page was still there. But deciding based on a wait time risks closing the tab while it's still on its way back to a heavy page. Rather than break the tab with an inaccurate check, it's better to accept that there will be cases where it misses, and say so honestly. That's why I split it into two buttons.
I also planned for cases where scripts don't run. If the redirect lands in an iframe whose embedder has disabled scripts with the sandbox setting, the warning page's JavaScript won't run. So the HTML ships with a fixed warning message, in both Japanese and English, from the start. Even without the details, the one line "PunyGuard blocked navigation to this page" gets through.
One more thing: if the URL contains a password, as in https://user:password@..., it's removed both from the display and from the warning page's own address bar. Exposing a secret on screen as a result of blocking would defeat the purpose of a protective tool. That said, the other Shossan living in my head does feel like "you didn't have to go that far..."
The scariest defense is the one that fails silently
The thing I most want to avoid in a security tool is a state where it's broken and nobody notices. When it looks like it's working but actually isn't protecting anything. In developing PunyGuard, I spent a lot of time figuring out how to stamp out these "silent failures."
The first pitfall was regex dialects. DNR regexes aren't interpreted by JavaScript's regex engine but by a separate one called RE2. RE2 doesn't support some constructs, like lookahead, and Chrome also rejects regexes that are too large. Meanwhile, ordinary unit tests run on JavaScript regexes. Even if you use something RE2 can't handle, the tests pass. Ship it like that, and rule registration fails, and nothing gets blocked. And nothing at all happens on screen.
I considered testing with a package that lets you call RE2 from Node instead. But that package translates JavaScript-style syntax for RE2 before running it, so it even lets through syntax that Chrome would reject. No matter how much you check in a similar environment, you won't get the same answer as production.
What I ended up choosing was to have Chrome itself try it. In end-to-end tests (E2E tests) that actually drive a browser, I load the extension into a real Chromium. Then I pass the exact rule I'm going to register to Chrome's isRegexSupported and have it answer whether it can be used. In the end, only production knows the answer. So run it in production.
Even so, the chance of registration failing on a user's machine isn't zero. When that happens, a ! badge shows up on the toolbar icon.
There was one more easy-to-miss hole. On Chrome's extensions page, users can restrict site access to "On click" or to specific sites. When they do, rule registration still succeeds, but only the redirect stops working. No error shows up anywhere. So I added a check for access to all sites, and if it's missing, the same ! appears. You can see the cause in the tooltip that appears when you hover over the icon. To keep a stale check result from overwriting the badge when access is toggled quickly, the checks run one at a time, in order.
In a normal window, if there's no !, blocking is working. I wanted to make it unmistakable that the settings and state are correct.
Say what it can't do
PunyGuard has a few weaknesses I left in knowingly.
One is a known false positive. If xn-- appears in the username part of a URL, as in https://xn--a@example.com/, it gets blocked even though the host is plain ASCII. Fixing it properly would take a pretty complex regex within RE2's constraints. I've never seen anyone open a URL like this, and the only harm is the blocker being wrong, so for now it stays as specified behavior, and there's a test confirming it.
Another is the back button. If the warning page opens as the first page of a new tab, and you navigate elsewhere and then come back with the browser's back button, "Go back to the previous page" is shown, but pressing it does nothing. That's the price of "accepting cases where it misses" from earlier; even in this state, "Close this tab" works.
What I struggled with most was incognito windows. Chrome doesn't run extensions in incognito windows unless the user turns on "Allow in Incognito." If it isn't allowed, IDN hosts aren't blocked, and the badge doesn't warn you either. Showing ! forever to someone who has deliberately chosen not to allow it would be unkind.
Even when it is allowed, the bundled warning page can't be shown in incognito tabs. Navigation to IDN hosts is blocked, but the screen shows Chrome's ERR_BLOCKED_BY_CLIENT, and the address bar keeps the original URL, password and all. Switching the extension's operating mode might have made it possible to show the warning page. But that would mean reworking the foundation of the design, and all it would buy is the "show the reason" part, not the "block" part. Since blocking itself works, this time I only documented it in the README.
Hand repetitive work to machines
Once I actually built it, I spent more time on the machinery around PunyGuard than on PunyGuard itself.
The development environment is generated from copier-ts7, a template for TypeScript 7 that I made. The Node.js and pnpm versions are pinned with a tool called Nix, so the same versions run both on my machine and in CI, which runs automated tests on GitHub. A single pnpm run check runs, in order, type checking, linting, format checking, and tests that require at least 80% coverage.
Releases are handled by an automation tool called release-please. As long as commit messages follow a set format, every time a change lands on main, it automatically creates a PR with the next version and the changelog. Merging that PR creates a tag and a GitHub Release, and bumps the version in package.json and manifest.json. A workflow then builds the tagged commit and attaches punyguard-vX.Y.Z.zip to the Release. Handy. I always forget to bump version numbers, so most of the apps I publish manage their versions this way.
What the human doesn't hand over when building with AI
As has become my usual lately, I built PunyGuard together with Claude Code. Write a plan, write tests first, then implement, set up E2E tests, build the release pipeline. Most of the code writing and running was done by the AI. And for every PR, I had Codex review it. I've set up skills so all of this happens entirely within Claude Code.
This combination is very effective, and not just for this app. The :~: URL truncation, tests and the shipped build loading different files, the warning page not opening in incognito windows: all of these were first found in Codex reviews. Separating the writer from the reader makes it less likely that both fall into the same assumptions and the same unnecessary biases. The logic is the same as asking someone else to review your code on a human team.
On the other hand, there's one thing I didn't hand to the AI: making the decisions.
I'm the one who decided to block legitimate Japanese domains too. The AI's reaction was along the lines of "surely Japanese is fine...", but as I keep saying, I dislike Japanese domains. Narrowing the check to hostnames, and deciding to handle the incognito problem in the README only, were also all my calls. The AI lays out the options and the cost of each, carefully. But which cost to accept can only be decided by a human who knows the circumstances of the people using it. I can say the pain of false positives is small precisely because I'm someone who dislikes Japanese domains.
That's why I always write down what I decide. Decisions, their reasoning, and the options I considered and rejected go in ADRs. Definitions of terms go in a glossary (CONTEXT.md). The plan, progress, and deferred issues with their reasons go in the plan document (tasks/todo.md). When working with AI, conversation memory gets summarized mid-way or cut off across days. If I had to explain everything from scratch each time, the context wouldn't be enough, and neither would my patience. If it's written down, the AI can interpret it in the next session and start from the same premises. The same goes for me reading it back six months from now.
I also kept the final check as a human job. Links opened from external apps, switching Chrome's display language, the badge when site access is restricted, browser restarts, incognito windows. These are hard to verify with automated tests, so I checked each one myself in a real Chrome. You must not trust AI-written code on the strength of AI-written tests alone. Only after checking with my own eyes can I look anyone in the eye and say "it works."
AI lends you more hands and an extra pair of eyes. Deciding what to build, what to give up, and whether it really works is, for now, the human's role. Draw that line clearly, and you can hand work to AI with peace of mind.
I didn't choose the path of better detection
There was surely a path of making the detection smarter. Grow the list, refine the similarity calculation, keep adding exceptions. PunyGuard didn't take that path. I made one single decision, not to judge, and handed the job of upholding it to a machine that never gets tired.
Choose simplicity that never hesitates over competing on cleverness. That's the distance I want to keep when working with computers and AI.
I decided not to put PunyGuard on the Chrome Web Store. It's distributed only on GitHub. Grab punyguard-vX.Y.Z.zip from Releases, unzip it, turn on Developer mode at chrome://extensions, and load it, and it'll work. Please leave site access set to "On all sites." If you don't use Japanese domains and you're worried about phishing with look-alike domains, I'd be glad if you gave it a try.
Why not the Web Store? It's something I made for myself, and I just didn't want to start fielding "please make it do this!!" requests from other people. That said, if you share Shossan's philosophy, feel free to use it.
sho7650/punyguard
Mentions
Code generator開発記(Part 2) ! この記事は2023年7月3日に執筆されたものです。 こんにちは。Inflabのフロントエンドエンジニア、Lucasです。 前回の記事では、code generatorの必要性と、自前で実装するに至るまでの悩みを共有しました。今回は、code generatorをはじめ、schema validator、type guardをどのように実装したのかを簡単にご紹介したいと思います。 最初にお伝えしておくと、これはとてもシンプルなアイデアで、実装に複雑な技術は必要ありません。その前提で読み進めていただければと思います。 1. 目標と必要な機能 まず、前回の記事で立てた目標について改めて考えてみます。私はcode generatorを実装する前に、次のような目標を立てました。 ファイルまたはネットワークリクエストでOpenAPI specification JSONを直接パースし、望む形の結果をファイルとして生成できること。 フロントエンドエンジニアが、APIのschemaの変化にできるだけ手間なく対応できること。 フロントエンドエンジニアが、生成物として出力したいAPIを選択できること。 APIで宣言したresponseをJSON validatorで検証できるtype guardが自動生成されること。 バックエンドエンジニアが、フロントエンドの手間を気にせずAPIを作れること。 バックエンドエンジニアが宣言したSwaggerと実際のAPI responseの形が一致しているかを、integration testで検証できること。 特に最後の目標は、Elixirのdoc testの概念を借りたものです。昨年取り組んだ個人プロジェクトでテストを書いた際の感触が良く、GraphQLがサポートしているようなschemaの整合性検証も、近い形なら実現できるのではないかと期待していました。特に私たちのバックエンドチームはテストを書くことを非常に重視しているので、OpenAPI specificationと実際のAPIの結果をテストする手軽な方法を提供できるだろうと考えました。 以下、OpenAPI specification JSONはOASと略します。 2. 使用したライブラリ まず、実装に使ったライブラリを紹介します。おそらくほとんどの方がご存じだと思います。 1. 実装 inversify 機能が増えるほど、コードベースは必然的に大きく複雑になるだろうと考えました。また、複数のオプションをサポートする予定だったので、実行時に動く実装も変わりうると見ていました。 そのため機能間の依存関係が複雑になりそうだと考え、悩んだ末に依存性注入(DI)のためのIoCコンテナを探すことにしました。私はinversifyを選びましたが、もう少し軽量に使いたいのであれば、typediという選択肢もあります。 json-schema-to-typescript 事実上、code generatorの中核となるライブラリです。Code generatorのほぼすべての実装は、このライブラリから望む結果を得るためのオブジェクトを作る作業だと言っても過言ではありません。例えば、次のようなJSON schemaがある場合、 { "title": "Example Schema", "type": "object", "properties": { "firstName": { "type": "string" }, "lastName": { "type": "string" }, "age": { "description": "Age in years", "type": "integer", "minimum": 0 }, "hairColor": { "enum": ["black", "brown", "blue"], "type": "string" } }, "additionalProperties": false, "required": ["firstName", "lastName"] } 次のように型を生成します。 export interface ExampleSchema { firstName: string; lastName: string; /** - Age in years */ age?: number; hairColor?: 'black' | 'brown' | 'blue'; } lodash object-traversal js-yaml オプションをYAML形式で受け取るつもりでインストールしました。今思えば、普通にJavaScriptで受け取っても問題なかったと思います。 jsonschema Code generatorが受け付けられるオプションを検証するために選びました。オプション関連のコードがif ~ elseだらけになるのを避けたかったのです。 2. ビルド、デプロイ TypeScript tsup esbuildベースのビルドツールです。速くて設定が簡単なので選びました。 Release Please ライブラリではありませんが、このGitHub Actionのおかげで手軽にデプロイできたので紹介します。 コミットメッセージの中にconventional commit形式で書かれたものがあれば、pull requestの作成からデプロイまで自動で行ってくれます。特にchange log、release、git tagなども自動で管理してくれるので、開発者はコードだけに集中できます。 ちなみに、Inflabのフロントエンドチームでは、monorepoで構成されたパッケージはchangesetsで、単独のリポジトリで運用しているパッケージはRelease Pleaseで管理しています。 commitlint 上で説明したRelease Pleaseの動作の仕組み上、開発者のミスを防ぐために導入しました。コードの変更がコミットされる前にコミットメッセージを検査し、conventional commitの形式に従っていなければコミットできないようにします。 コミットメッセージの一貫性を保ち、デプロイのためにコードを変更したのに、コミットメッセージの誤りでパッケージがデプロイされないという問題を防ぐのが狙いです。 3. テスト Jest 3. 実装の詳細 Code generatorは、大きく3つの段階に分けて実行されます。1つ目は前処理の段階です。正しいオプションが渡されたかを検証し、OASのJSONファイルを読み込みます。続いて、ユーザーが望む前処理ロジック(Pre process)を実行して、読み込んだOAS JSONを加工します。 2つ目の段階では、Taskと呼ばれる作業単位が並列に実行されます。どのタスクを実行するかは、そのタスクのオプションが正しく入力されているかどうかによって決まります。 ReactQueryTaskでは、前処理済みのOAS JSONから生成物に使うオブジェクトを作り、適切な名前と対応付けます。Query、url params、response、request bodyなど、必要と思われる要素をすべて抽出し、型とhooksに変換します。 TypeGuardTaskでは、ReactQueryTaskで作られたインターフェースを検証するtype guard関数を作ります。 SchemaValidatorTaskは、OAS JSONで宣言されたすべてのschemaを検証できるstandaloneのAjvインスタンスを生成します。Ajvについては、後ほど改めて詳しく紹介します。 Taskの並列実行が終わったら、最後の段階として、指定された場所へファイルを書き出します。フローチャートで表すと、次のようになります。 1. オプションの入力を受け取る ユーザーから希望するオプションをYAML形式で受け取るように構成しました。そのオプションは次のとおりです。 loader: fetch | file [required] schema: string [required] output-dir: string [required] react-query: [optional] output: string [required] fetcher: string [required] version: 3 | 4 [optional] default: 3 validator: [optional] output: string [required] typeguard: [optional] output: string [required] preprocess: [optional] filterDeprecated: boolean [optional] default: false extractEnum: boolean [optional] default: false apiPaths: - path: string [required] exact: boolean [optional] tags: - string [required] 2. 前処理 1. オプションの検証 YAMLファイルの有無の確認、node argumentsの入力チェック、kebab-caseのオプションのcamelCaseへの変換など、I/Oと副作用はConfigProviderが担当するようにしました。 一方、react-query、preprocessなど各オプションのパース、検証、値の設定は、ConfigLoaderを実装したオブジェクトが担います。各オブジェクトがパースしたオプションは、ConfigProviderに渡されます。こうしたのは、オプションごとの変更の影響を互いに切り離すためです。 例えば、react queryを担当するクラスは次のように実装されています。 import Ajv from 'ajv'; import type ConfigLoader from './ConfigLoader'; import type { JSONSchemaType } from 'ajv'; import { isNil } from 'lodash'; import { InvalidConfigException } from '../utils/exception'; type ReactQueryConfigOptions = { output: string; fetcher: string; version?: number; removeApiEndpointPrefix?: boolean; }; type YamlReactQueryConfig = { reactQuery?: ReactQueryConfigOptions; }; /** - 1. react-queryオプションのjson validationロジック */ const schema: JSONSchemaType<YamlReactQueryConfig> = { type: 'object', properties: { reactQuery: { type: 'object', nullable: true, properties: { output: { type: 'string' }, fetcher: { type: 'string' }, version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 }, removeApiEndpointPrefix: { type: 'boolean', nullable: true }, }, required: ['output', 'fetcher'], }, }, additionalProperties: true, }; const validate = new Ajv({ useDefaults: true }).compile(schema); export type ReactQueryConfig = ReactQueryConfigOptions | false; export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> { /** - 2. react-queryのオプションの検証 */ load(optionCandidate: unknown) { if (!validate(optionCandidate)) { throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config'); } /** - 3. react-queryのオプションを返す - 戻り値の型はReactQueryConfigOptions | false */ if (isNil(optionCandidate.reactQuery)) { return false; } return optionCandidate.reactQuery; } } ReactQueryConfigLoaderでパースしたオプションは、ConfigProviderのパブリックメソッドとして提供されます。 export class ConfigProviderImpl implements ConfigProvider { private loader: Loader; private options: Options; constructor() { /** - 1. yamlファイルの読み込み */ const yamlCandidate = this.loadConfig(); const reactQueryConfigLoader = new ReactQueryConfigLoader(); /** - 2. react-queryのオプションのパースを依頼 */ const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate); this.options = { reactQuery: reactQueryConfig, }; } private loadConfig() { const flags = args.parse(process.argv); const yamlPath = resolve(process.cwd(), flags.config); if (typeof yamlPath !== 'string') { throw new InvalidPathException('ConfigProvider: failed to open yaml config file'); } const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8'))); return yamlConfig; } getOptions(): Options { return this.options; } /** - 3. オプションが必要なオブジェクトにオプションを提供 */ taskOption(): TaskOption { return pick(this.options, ['reactQuery']); } } この構造のおかげで、ConfigLoaderを実装するだけでConfigProviderから使えるようになります。つまり、オプションはいつでも柔軟に追加・変更できます。 2. OpenAPI specificationファイルを受け取る 当初の目標の中に、「ファイルまたはネットワークリクエストでOAS JSONを直接パースし、望む形の結果をファイルとして生成できること。」がありました。これを実現するために、Loaderを実装したFileLoaderとFetchLoaderを作り、inversifyのfactory関数を使って、オプションに応じて使うべきローダーを選択するようにしました。 // inversify.config.ts const container = new Container(); /** - 1. IoCコンテナにオブジェクトを登録 */ container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch'); container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file'); /** - 2. ファクトリー関数の設定 */ const loaderFactory = container .bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory) .toFactory<Loader, ['file' | 'fetch']>((context) => { return (named) => { /** - 3. どのオブジェクトをどのように読み込むかを明示 */ return context.container.getNamed<Loader>(TYPES.Loader, named); }; }); // loader.ts const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader); このように実装すると、既存のコードを修正せずに別のLoaderを追加できるというメリットがあります。例えば、databaseからOAS JSONを読み込む必要がある場合は、次のように対応できます。 class DatabaseLoader implements Loader { // ... } container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db'); const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db'); 3. Preprocess pipe Preprocess pipeは、読み込んだOAS JSONをさまざまな条件で加工する工程です。DeprecatedなAPIを除外したり、特定のAPIだけを選択したり、enumの抽出を選択したりといった処理を担当します。preprocessのオプションはいろいろ選べ、こちらもオプションに対応する前処理のロジックが実行時に決まります。PreprocessPipeクラスが、先ほど紹介したfactory関数でPreprocessを実装したクラスを選んで配列に入れ、OAS JSONを順番に渡しながらロジックを実行します。サンプルコードは次のとおりです。 import { flow } from 'lodash'; class PreprocessPipe { private readonly preprocessOptions: PreprocessConfig; private readonly initialPreprocess: Preprocess[]; constructor( @inject(TYPES.PreprocessFactory) private readonly preprocessFactory: (name: string) => Preprocess, ) {} exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined { /** - 1. オプションで選択されたpreprocessの実装だけを選んで配列に入れる */ const preprocess = this.scheduledPreprocess(); const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess)); /** - 2. preprocessの実装を順番に実行 */ const result = flow(preprocessorFunctions)(swaggerSpecJSON); return result; } private scheduledPreprocess() { if (!this.preprocessOptions) { return []; } /** - enabled = trueのオプションのオブジェクトだけを読み込む */ const selectedPreprocess = Object.entries(this.preprocessOptions) .filter(([, enabled]) => enabled) .map(([name]) => this.preprocessFactory(name)); return this.initialPreprocess.concat(selectedPreprocess); } } こちらも、オプションを追加・削除しても既存のロジックに影響しないように実装しています。オプションの読み込みと検証、OAS JSONファイルの読み込み、preprocessの実行という前処理の段階は、使う場所によって実行のされ方が変わりうるため、できるだけ柔軟に設計しました。 3. ReactQueryTask 上で説明したとおり、json-schema-to-typescriptというライブラリで望む生成物を得るためにオブジェクトを作る工程です。ある意味で最も重要な部分なので、責務を細かく分けることに気を配りました。基本的な流れは次のとおりです。 Generator役のオブジェクトは、Parser役のオブジェクトに必要な部分のパースを依頼します。Parserが結果を返すと、それぞれの役割に応じて生成物を文字列として組み立てます。 Object Generatingを担当するオブジェクトは、パースされたデータを使って適切な形のオブジェクトを作ります。インターフェースとして生成される名前を指定して、json-schema-to-typescriptに渡します。例えば次のとおりです。 import { compile } from 'json-schema-to-typescript'; const queryString = queryStringParser.generate(oasJson); /** - { - "title": "V1UserListQueryString", - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - } - } - } */ const result = await compile(queryString); /** - export interface V1UserListQueryString { - firstName?: string; - lastName?: string; - } */ react-queryで使うhooksを作るオブジェクトも、同じくパースされたデータから適切な形のhooksの文字列を作ります。Hooksの場合は、template literalを使えば生成結果を簡単に宣言できます。 // template.ts export default function useMutationTemplate(name: string, method: string) { const type = ${name}Response; const optionType = UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>; return `export function use${name}Mutation<TContext>({ url, options, requestOptions, }: { url: string; options?: ${optionType}; requestOptions?: RequestOptions; }) { const result = useMutation<${type}, unknown, ${name}Variables, TContext>( (variables) => fetcher.${method}(url, variables, requestOptions), options, ) return { ...result, data: result.data?.data }; }`; } // ReactMutationGenerator.ts const result = useMutationTemplate('V1UserList', 'POST'); このようにgenerate layerで作られた結果は、それぞれstring[]の形でReactQueryTaskに戻されます。ここまでの説明は長くてピンとこないかもしれませんが、次のダイアグラムを見ていただければ、思ったよりシンプルな構造であることがおわかりいただけると思います。 4. TypeGuardTask 1. Type guardとは何か TypeScriptにおけるtype guardとは、複数の型を取りうる変数について、型の推論範囲を絞り込む(narrowing)ための関数のことです。例えば次のとおりです。 // type guard const isString = (value: unknown): value is string => typeof value === 'string'; const numOrStringDivide = (value: string | number) => { if (isString(value)) { return parseInt(value, 10) / 2; // value -> string } return value / 2; // value -> number }; フロントエンドでtype guardが必要になるのは、上の例のようにprimitive typeを区別したい場合もありますが、API responseの違いを見分けて処理を分岐させなければならない場面が出てくるからです。特に、通常の関数だけでなくcomponentの中でも便利です。例えば、API responseの形に応じてレンダリングするcomponentを切り替えたい場合です。こうした場面で、Inflabのフロントエンドチームはtype guardを積極的に使っています。 const isStudent = (value: Student | Teacher): value is Student => { return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string'; }; export function Profile() { // Student | Teacher const { data } = useProfileQuery(); return ( <div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div> ); } 2. Type guardの落とし穴 もちろん、GraphQLを使えば__typenameというプロパティを使って簡単に区別できます。しかし、そうでない場合は一般的に、API responseの形を必要な分だけパースする方法で型を区別することになります。上の例で言えば、ある値がオブジェクトで、'grade'というstring型のプロパティを持っていれば、'Student'型とみなすことになります。 これはTypeScriptの構造的型システム(structural type system)に起因するもので、よくまとまったドキュメントが多いので、ここではこれ以上説明しません。 本題に戻ると、Inflabのフロントエンドチームではtype guardを積極的に使っていますが、メリットと同じくらいデメリットもはっきりしていました。それは、type guard自体が誤って宣言されている場合、ロジック上の誤りが表に出てこないということです。代表的なのが、API responseが変わったのにtype guardを変更し忘れたケースです。 上の'Student'型で言えば、API responseが変更されて'grade'というプロパティの型が変わったり、プロパティ自体がなくなったりするケースが考えられます。こうなると、コードレビューをしても見つけにくいだけでなく、コンパイル時には何のエラーも発生しないため、ランタイムで問題が発覚することが多いです。 3. Type guardを自動で作る こうした理由から、type guardもOAS JSONから自動で作られたらどうだろう? というアイデアが浮かびました。同僚と議論した結果、jsonschemaを使ってOASのschemaを検証できる形にし、ファイルとして出力する機能を実装することに決めました。実装全体の流れは次のとおりです。 まず、OAS JSONをJSON schemaの形に変換し、JSON schemaがサポートしていないプロパティを取り除きます。 import { draft7 } from 'json-schema-migrate'; import { unset } from 'lodash'; function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) { const componentSwaggerSpecs = swaggerSpec.components.schemas; /** - 1. OAS -> json schemaの形に変換 */ draft7( toJsonSchema(swaggerSpec.components.schemas, { strictMode: true, }), ); /** - 2. json schemaでサポートされていないプロパティを削除 */ removeUnSupportProperty(componentSwaggerSpecs); return componentSwaggerSpecs; } function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) { const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example']; traverse(componentSwaggerSpecs, ({ parent, key }) => { key ??= ''; if (notSupportedType.includes(key)) { unset(parent, key); } }); } こうして作ったJSON schemaを、template関数を使って文字列にすれば完成です。 const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => { return [ importTemplate(Object.keys(jsonSchemas)).trim(), 'const validator = new Validator();', Object.entries(jsonSchemas) .map(([key, schema]) => typeGuardTemplate(key, schema)) .join('') .trim(), ].join('\n\n'); }; const importTemplate = (keys: string[]) => { return ` import { Validator } from 'jsonschema'; import type { ${keys.join(', ')} } from './schema'; `; }; const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => { const stringifyJsonScheme = JSON.stringify(jsonSchema); return ` export const ${is${type}} = (value: unknown): value is ${type} => { return validator.validate(value, ${stringifyJsonScheme}).valid; } `; }; const schema = convertSwaggerSpecToJSONSchema(oasJson); const result = defaultTypeGuardTemplate(schema); まだ完璧に動作するわけではありませんが、問題なく使えるようドッグフーディングを続けています。 5. SchemaValidatorTask 上でもお伝えしたとおり、Elixirのdoc testの考え方を取り入れ、バックエンドエンジニアが自分の宣言したOASと実際のresponseが一致しているかを、integration testの段階で検証できる機能を提供しようとしました。ライブラリにはAjvを選びました。バックエンド側で別途ライブラリをインストールしなくても、standaloneモードで動作するからです。 SchemaValidatorTaskの仕組みは、前の2つのタスクに比べてとてもシンプルです。OASをJSON schemaに変換し、standaloneモードを適用した生成物を返せば完了です。 import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema'; import Ajv from 'ajv'; import standaloneCode from 'ajv/dist/standalone'; const schema = toJsonSchema(oasJson); const ajv = new Ajv({ schemas, code: { source: true, optimize: true, }, }); const result = standaloneCode(ajv); 6. 生成物の出力 各タスクでは、受け取ったstring配列から一定の基準で重複を取り除きます。ここまで問題がなければ、オプションで指定されたパスに結果を出力します。 4. 生成物の形 次のようなOAS JSONを受け取ったと仮定すると、各タスクの実行結果は以下のとおりです。 OAS.json{ "openapi": "3.0.0", "paths": { "/api/v1/category": { "get": { "operationId": "CategoryB2cController_findAll", "summary": "すべてのカテゴリー一覧", "description": "すべてのカテゴリー一覧を取得する。", "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { "properties": { "statusCode": { "type": "string" }, "message": { "type": "string" }, "data": { "$ref": "#/components/schemas/CategoryAllResponse" } } } } } } }, "tags": ["カテゴリーenum API"] } } }, "components": { "schemas": { "CodeName": { "type": "object", "properties": { "code": { "type": "string" }, "name": { "type": "string" } }, "required": ["code", "name"] }, "JobGroupCodeName": { "type": "object", "properties": { "code": { "type": "string" }, "name": { "type": "string" }, "jobs": { "type": "array", "items": { "$ref": "#/components/schemas/CodeName" } } }, "required": ["code", "name", "jobs"] }, "CategoryAllResponse": { "type": "object", "properties": { "jobGroup": { "type": "array", "items": { "$ref": "#/components/schemas/JobGroupCodeName" } }, "jobSeekerStatus": { "type": "array", "items": { "$ref": "#/components/schemas/CodeName" } } }, "required": ["jobSeekerStatus"] } } } } ReactQueryTaskimport { useQuery, useMutation } from '@tanstack/react-query'; import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query'; import fetcher from './Fetcher'; import type { RequestOptions } from './Fetcher'; export type Maybe<T> = T | null; export type OneOf<T> = T extends unknown[] ? T[number] : T extends readonly unknown[] ? T[number] : T; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]-?: Exclude<T[K], undefined>; }; export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U> ? U : T extends { [key: string]: infer U } ? U : never; export const enum ResponseStatus { OK = 'OK', SERVER_ERROR = 'SERVER_ERROR', DUPLICATE = 'DUPLICATE', NOT_FOUND = 'NOT_FOUND', BAD_REQUEST = 'BAD_REQUEST', BAD_PARAMETER = 'BAD_PARAMETER', CONFLICT = 'CONFLICT', UNAUTHORIZED = 'UNAUTHORIZED', FORBIDDEN = 'FORBIDDEN', PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE', TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', } export type Success<T = unknown> = { statusCode: ResponseStatus.OK; message: ''; data: T; }; export type Error = { statusCode: Omit<keyof typeof ResponseStatus, 'OK'>; message: string; data: Maybe<''>; }; /** - @typeParam T - Response data type */ export type ApiResponse<T = unknown> = Success<T>; export const API_ENDPOINT = { V1_CATEGORY: '/api/v1/category', } as const; export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) => fetcher.get<V1CategoryResponse>(url, requestOptions); export function useV1CategoryQuery({ queryKey, url, options, requestOptions, }: { queryKey: QueryKey; url: string; options?: Omit< UseQueryOptions<V1CategoryResponse, unknown>, 'queryKey' | 'queryFn' | 'initialData' > & { initialData?: () => undefined; }; requestOptions?: RequestOptions; }) { const result = useQuery<V1CategoryResponse, unknown>( queryKey, () => fetchV1Category(url, requestOptions), options, ); return { ...result, data: result.data?.data }; } /** - created by ResponseSchemaGenerator */ export interface V1Category { data: Maybe<CategoryAllResponse>; } export interface CategoryAllResponse { jobGroup: Maybe<JobGroupCodeName[]>; jobSeekerStatus: CodeName[]; } export interface JobGroupCodeName { code: string; name: string; jobs: CodeName[]; } export interface CodeName { code: string; name: string; } export type V1CategoryResponse = ApiResponse<V1Category['data']>; TypeGuardTaskimport { Validator } from 'jsonschema'; import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema'; const validator = new Validator(); export const isCodeName = (value: unknown): value is CodeName => { return validator.validate(value, { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }).valid; }; export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => { return validator.validate(value, { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' }, jobs: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['code', 'name', 'jobs'], }).valid; }; export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => { return validator.validate(value, { type: 'object', properties: { jobGroup: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' }, jobs: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['code', 'name', 'jobs'], }, }, jobSeekerStatus: { type: 'array', items: { type: 'object', properties: { code: { type: 'string' }, name: { type: 'string' } }, required: ['code', 'name'], }, }, }, required: ['jobSeekerStatus'], }).valid; }; SchemaValidatorTaskの結果は1つの複雑なファイルになるため、ここでは紹介を割愛します。 こうして作られた生成物は、フロントエンドのコードで次のように使えます。 import { useV1CategoryQuery, API_ENDPOINT } from './schema'; import { isJobGroupCodeName } from './guard'; const retrieveJobGroupCodeNameJobs = (value: unknown) => { if (isJobGroupCodeName(value)) { return value.jobs; } return []; }; export function A() { const { data } = useV1CategoryQuery({ url: API_ENDPOINT.V1_CATEGORY, queryKey: [API_ENDPOINT.V1_CATEGORY], }); return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>; } バックエンドのテストコードでは、schema validatorを次のように使えます。 import validator from './validator'; describe('categoryAllResponse', () => { it('categoryAllResponseのOASと実際の結果は一致しなければならない。', async () => { const response = await controller.categoryAllResponse(); const result = validator.categoryAllResponse(result); expect(result).toBe(true); }); }); 5. まとめ 最初にお伝えしたとおり、一見すると工程は複雑に見えますが、まったくそんなことはありません。むしろ実装中に悩んだのは、責務をどう適切に分担するかと、SonarQubeのduplicate line code smellを避けるためにコードの重複をどう抑えるかでした。そのため、コード全体を通して次のような選択をしました。 各オブジェクトの責務を細かく分離するよう努めました。 各オブジェクトの横断的関心事はServiceというlayerに分け、必要なオブジェクトが必要な分だけ参照するように実装しました。 実行時に動くロジックを切り替える必要がある場合は、Strategyパターンを積極的に使いました。 同じロジックで小さな部分だけを変える必要がある場合は、Template Methodパターンを使いましたが、継承が1段階を超えないように実装しました。 実装した構造を簡単に表すと、次のようになります。 実装の紹介は以上です。 次回の記事では、code generatorを導入してから働き方がどのように変わったのかをご紹介します。
This is a real gem: googleapis/release-please Thanks @seuros!!
Conventional Commits に切り替えて、本当に変わったこと 最初期の履歴には、nuxt.js Learning、bug fixed、almost finishedというコミットが残っています。当時はそれで困らないと思っていました。数か月後、自分で変更理由を調べるまでは。 それからConventional Commitsを個人のルールとして使い始めました。2年分の履歴を集計すると、type付きコミットの比率は初期の約2%から、後期プロジェクト全体で約65%まで増えています。ただし、自動リリースやCHANGELOG生成まで整備したわけではありません。この記事では、改善した部分と、今も雑なまま残っている部分の両方を書きます。 半年後の自分が履歴を読めなかった Conventional Commitsを使って得た一番直接的なメリットは、CHANGELOGの自動生成ではなく、半年後の自分でもgit logを読めるようになったことでした。 働き始めた頃の私は、commit message を思いついたまま書いていました。2 年分のデータを後から集計してみると、いくつかの比率がなかなか目を引きます。 以下の「後期」は複数プロジェクトを合算した値です。単一プロジェクトでは24~90%程度のばらつきがあり、どのリポジトリも一様に改善したわけではありません。 観点 初期(最初のいくつかのプロジェクト) 後期(後期プロジェクト合計) semantic prefix 付き(feat/fix/refactor) 約 2% 約 65% body 付き(少し説明を足す) 約 5% 約 60%(プロジェクト間で 24〜90%) 複数行 message 約 2% 約 60% 平均 message 長 約 7 語 約 9〜10 語 数字だけを見るときれいですが、実際には定着まで時間がかかり、今も規約外のコミットが残っています。 背景:当時の commit message はどれだけ雑だったか 入社後に最初に引き継いだ業務プロジェクトは、ある製造業のお客さま向けの倉庫管理 UI でした。当時の私は Nuxt を触り始めたばかりで、commit は「ひと区切りついたら一筆書く」という状態でした。 当時のメッセージをいくつか拾ってみます(固有名詞は匿名化していますが、雰囲気はこのままです)。 nuxt.js Learning try to show pics in the xxxPage bug fixed almost finished bug repair and change some code format except the double floor task creation func, almost finish bug fixed ってどの bug を直したのか? almost finished は何が 90% 完了したのか? nuxt.js Learning の行に至っては、フレームワークを触り始めたばかりの頃の「日記」です。当時はプロジェクトが動いて、ちゃんと納品できていれば、message がこんな調子でも誰も何も言いませんでした。 問題はそのあとです。数か月後に「この状態切り替えのロジックはどの commit で変えたんだっけ」と調べに戻ると、git log --oneline を bug fixed / almost finished と一行ずつたどっていくしかなく、まったく当てになりませんでした。最後は git log -p で diff を一区切りずつ読む羽目になり、コードを読み直すより遅いくらいでした。 その頃の私はこれを「問題」だとは思っておらず、履歴を調べるのが面倒くさいな、と諦めていただけでした。 転機:自分で立ち上げたあのプロジェクトから 転機は 2025 年の半ばでした。私が初めてゼロから自分でプロジェクトを立ち上げたときです。それまではずっと他人のコードを引き継いで上に足していく形でしたが、このときはリポジトリ全体を自分で組みました。 不思議なもので、最初の commit から自分で作り始めたからこそ「今回はちゃんと書きたい」という気持ちが湧きました。Conventional Commits の規約を真面目に読んだのもこの頃です。中身は拍子抜けするほどシンプルでした。 <type>(<scope>): <summary> <body 任意> type はよく使うものが数種類だけ。feat(機能追加)/ fix(バグ修正)/ refactor(リファクタ)/ chore(雑務)/ docs(ドキュメント)。私はツールは一切入れず、そのプロジェクトから commit のたびにまず「これはどの type か」を考えてから summary を書くようにしました。「そのプロジェクトから」と書いて「初日からできた」と書かないのには理由があります。そのプロジェクトでいうと、init から、毎回ちゃんと feat:/fix: 接頭辞を付けられる状態になるまで、習慣として定着するのに 2 か月近くかかりました。規約はスイッチではなく、じわじわ育っていくものです。 ここは正直に言っておきます。私は commitlint / husky のような機械的にチェックするツールは入れていないし、チームで強制されていたわけでもありません。 最初から最後まで、自分で自分に課した一本の規律です。なので以下のデータは「一人で続けた結果」であって、「CI で弾かれてコミットできない」結果ではありません。この 2 つの重みの違いは、自分でも分かっています。 before / after:変化は測れる 初期プロジェクトと後期プロジェクトの commit を並べて比べると、差ははっきり出ます。 初期は上のbug fixedの世界でした。後期はこうなりました(同じく固有名詞は匿名化しています)。 feat: enhance area selection, fix status bugs, and add charging task support - Allow locked areas to be clicked and show selected highlight - Fix toggle status bug: use level-based lookup instead of index-based - Add helper to disable areas containing equipment racks fix: Correct reachability logic for area accessibility check refactor: extract <component> logic into composables どこが違うのか。 その commit が何をするものか一目で分かる —— feat か fix か、接頭辞をさっと見るだけで足ります body に「何を / なぜ」変えたかが書いてある —— たとえば「index ベースの参照を level ベースに置き換えた」と書いてあれば、半年後の自分でも当時のハマりどころを思い出せます git log --oneline が読めるリストになる —— almost finished の羅列ではなく データで見ると(ここでの後期の数字はいずれも後期プロジェクト合計で、単一リポジトリではありません)、semantic prefix は約 2% から約 65% へ(おおよそ 30 倍)、body 付きは 5% から約 60% へ(プロジェクトによって 24% から 90% までと均一ではありません)、複数行 message は 2% から 60% へ、平均長は約 7 語から約 9〜10 語へ。はっきり指せるマイルストーンの一つが、2025 年 9 月の feat: Add automated status monitoring です。これはそのプロジェクトの init から 2 か月以上経ってからのことで、規約がじわじわ育つものだということを改めて示しています。 本当に変わったこと 数字は表面です。日常レベルで本当に変わったのは、この 3 つだと思っています。 第一に、git log が使えるものになりました。 今は「この機能はどの commit で入ったか」を調べるとき、git log --oneline --grep=feat を一発、あるいは接頭辞をさっと眺めるだけで特定できます。以前の git log -p で diff を一区切りずつ読む苦行は、ほぼなくなりました。 第二に、変更を見直す単位が揃いました。 1コミットに1つのtypeを付けると、未来の自分も「これは機能追加か、不具合修正か」を意識してdiffを読めます。bodyに理由を残したコミットは、コードだけを読み直すより判断が速くなりました。 第三に、自分の作業ログになりました。 これは思わぬ収穫でした。body 付きの commit を書き続けると、git 履歴そのものが「この 2 年で自分が何をやって、どんな落とし穴を踏んだか」の記録になります。職務経歴書を書くときや成長を振り返るとき、この履歴は記憶よりずっと当てになります。この記事のデータも、実はこの履歴から拾い出したものです。 正直な部分:できなかったことのほうが、たぶん語る価値がある ここで記事が終わったら、ただの「規約を入れたら最高」という宣伝文になってしまいます。でも実際はそんなに円満ではありません。私ができなかったいくつかの点こそ、この記事で残したい本題です。 100% 規約化には至っていません。 後期のリポジトリですら、HEAD にはまだこんな message が残っています。 constrained the retrieval of park(only type 0 and 6) 接頭辞なし、全部小文字、思いつくまま。傾向としては、機能追加やバグ修正のような「ちゃんとした」変更はきちんと feat: / fix: を付けるけれど、ちょっとした一時的な調整になると手を抜く、という感じです。あとで気づいたことが一つあります。100% 徹底する必要は実はないし、難しいということです。重要で、あとから振り返って調べる変更を規約に沿って書ければ十分で、一行残らず完璧にと突き詰めるのは、別の種類の消耗です。 1 つの commit に複数の type が混ざっていることがよくあります。 上の feat: ... fix status bugs, and add charging task support は、厳密には Conventional Commits の「1 commit に 1 つのこと」という理想に反しています。feat であると同時に fix でもあるからです。現実には作業中の変更がいくつもの関心事をまたぐことが多く、そこまできれいに分けるには commit の粒度にもっと気を配る必要があります。これはまだ練習中です。 最大の弱点:release / CHANGELOG の自動化はまったく手を付けていません。 Conventional Commits の最大の売りは、それをもとに CHANGELOG を自動生成し、SemVer のバージョンを自動で上げられることです。でも数えてみたら —— 9 プロジェクトを通じて git tag 0 個、release 0 個でした。つまり私は規約を入れたのに、その恩恵をまだ一口も食べていないのです。接頭辞をどれだけ整えても、tag と自動化がなければ「log が読める」という一層の価値しか発揮できず、残りの大半のポテンシャルは無駄になっています。 それは最後まで個人の規律であって、チームの規約ではありませんでした。 CI 上で commitlint が弾いてくれるわけではなく、すべて自覚頼みです。良いところは導入コストがゼロなこと、悪いところは —— 気を抜くと崩れること(上の接頭辞なしの行がその証拠です)。 次に直したいこと Conventional Commitsを個人のルールとして使い始めたことで、git logは少なくとも「後から検索できる記録」になりました。一方、これは運用の入口にすぎません。今も次の課題が残っています。 接頭辞は付けるようになりましたが、自動化はまだつないでいません。残っている課題は二つです。 commitlint + huskyを入れる。 commit-msgフックで規約に合わないメッセージを止め、人の注意力だけに頼らないようにする。 tag + 自動CHANGELOGをつなぐ。 release-pleaseなどを使い、コミット履歴をリリース記録に反映する。 次に同じ規模のプロジェクトを始めるなら、初日からcommitlintを入れます。私の場合、ルールを覚えるまでに約2か月かかりました。人の注意力だけに任せるより、最初から機械に止めてもらうほうが早かったはずです。それでも、bug fixedしか残っていなかった頃と比べれば、接頭辞を付ける習慣だけでも履歴の使い勝手は大きく変わりました。 参考リンク Conventional Commits(公式仕様) commitlint release-please standard-version
8 Paths Your Coding Agent Should Never Edit, and the Deny Rule for Each - Book: Event-Driven Architecture Pocket Guide - The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- The diff for a one-test fix touches four files: the test, package-lock.json, a migration that ran in production months ago, and the CI workflow, which the agent "tidied". Each change looks reasonable in isolation. Each one can cost you a day to unwind. Reading every diff catches some of this; a rule that refuses the edit up front catches more. Below are eight paths, the failure an agent edit causes in each, and how to block it in Claude Code, Codex, Antigravity CLI and Gemini CLI, plus a server-side backstop for agents that have no deny syntax at all. "Never edit" means never with the agent's own file tools. Lockfiles and generated code still change, through the package manager or the generator. That route stays open on purpose. The eight paths One warning first. Claude Code's docs say Read and Edit deny rules cover its built-in file tools, file commands it recognises in Bash such as cat, sed and tee, and redirection targets. They don't cover "arbitrary subprocesses", like a Python or Node script that opens files itself. Treat these rules as a filter on the obvious path. The OS-level wall is the sandbox. 1. Lockfiles npm ci "will exit with an error" when the lockfile and package.json disagree, according to the npm docs, so a hand-patched lockfile fails your next CI run. The worse case passes CI: a lockfile entry can resolve a package to a different tarball, and Liran Tal showed how easily that hides in a diff, asking "who really reviews thousands of lines of character changes?" (Snyk). His lockfile-lint checks the sources in CI. Rule: Edit(/package-lock.json), plus pnpm-lock.yaml, yarn.lock, uv.lock or composer.lock for whatever you use. The agent can still run npm install <pkg> and let npm write the file. 2. Migrations that already ran Flyway stores a CRC32 checksum for each migration it runs, and validation fails when "differences in migration names, types or checksums are found." Edit an applied file and the next migrate stops, because validateOnMigrate defaults to true (Flyway docs). Rails records each migration it runs by its version number, so after you edit an applied file it "thinks it has already run the migration and so will do nothing" (Rails guides). A fresh database gets the edited version, production keeps the old one, and nothing reports the difference. Rule: ask, not deny, on Edit(/db/migrate/**). The agent still needs to add new migrations, so every write prompts you and you reject the ones that touch old files. The CI check in the backstop section below enforces it. 3. CI workflows A workflow runs with your repository's secrets. In September 2025, a commit titled "Add Github Actions Security workflow" added a workflow that posted secrets to an attacker's server: 3,325 secrets from 817 repositories (GitGuardian). That was an attacker, not an agent. An agent "fixing CI" writes to the same file, with the same reach. Rule: Edit(/.github/workflows/**), and Edit(/.github/actions/**) if you keep composite actions there. 4. Generated code The next generator run overwrites any hand edit. Go marks generated files with a line matching ^// Code generated .* DO NOT EDIT\.$ (cmd/go). On GitHub, paths marked linguist-generated in .gitattributes are "hidden by default in diffs" (GitHub docs), so a hand edit there is easy to scroll past in review. Rule: Edit(/src/gen/**) (use your real output folder). The agent edits the .proto or OpenAPI source and runs the generator. The generator is a subprocess, so the deny rule doesn't stop it, which is what you want here. 5. Vendored dependencies go mod vendor "resets the main module's vendor directory" (cmd/go), so a patch inside vendor/ disappears the next time anyone runs it. Until then, the go command only checks that vendor/modules.txt matches the versions in go.mod (Go modules reference). The reference describes no check of the vendored files themselves, so a patched copy can build and ship without a warning. The durable fix is a fork and a replace directive. Rule: Edit(/vendor/**), and third_party/ if you have one. 6. CODEOWNERS CODEOWNERS decides who is asked to review what, and with branch protection, who must approve. GitHub reads it from the base branch of a pull request, so an edit doesn't change the reviewers of its own PR. Once it merges, though, it changes who reviews everything after. An agent "removing stale owners" can drop the only required reviewer for your payments folder. Rule: Edit(/.github/CODEOWNERS), or the root or docs/ copy if that is where yours lives. 7. Released changelog entries release-please writes CHANGELOG.md from Conventional Commits when you merge its Release PR. To fix a note, you add a BEGIN_COMMIT_OVERRIDE section to the merged PR's body (squash merges only), and release-please uses it on its next run, so it fixes notes for the coming release. An agent rewriting a released entry by hand makes the changelog disagree with the tag that shipped. Rule: Edit(/CHANGELOG.md). If you keep a hand-written "Unreleased" section, use ask instead. 8. Secrets files Reading .env sends its values to the model with the rest of the context. In Claude Code, a Read deny rule also blocks Edit and Write on the same path, including creating a new file there. NotebookEdit isn't covered, so add an Edit rule if you use notebooks (docs). Rule: Read(.env), Read(.env.*) and Read(*.pem). Bare filenames match at any depth, and a later Read(!.env.example) carves your example file back out. The whole block for Claude Code Commit this as .claude/settings.json. In project settings, a leading / anchors at the session's working directory, and in a worktree session Edit(/src/**) matches that worktree's own src/ (docs). Rules run deny, then ask, then allow, and an allow rule can't carve an exception out of a deny. { "permissions": { "deny": [ "Edit(/package-lock.json)", "Edit(/.github/workflows/**)", "Edit(/src/gen/**)", "Edit(/vendor/**)", "Edit(/.github/CODEOWNERS)", "Edit(/CHANGELOG.md)", "Read(.env)", "Read(.env.*)", "Read(!.env.example)", "Read(*.pem)" ], "ask": [ "Edit(/db/migrate/**)" ] } } Write rules as Edit(...), not Write(...). The docs say Claude Code accepts a Write(path) rule but never consults it, and warns at startup. Codex, Antigravity CLI and Gemini CLI Codex has permission profiles in config.toml. They are marked beta. Extending :workspace already keeps .git, .agents, .codex and .aws read-only, and a path set to read stays readable but not writable (docs): default_permissions = "guarded" [permissions.guarded] extends = ":workspace" [permissions.guarded.filesystem.":workspace_roots"] ".github" = "read" "vendor" = "read" "src/gen" = "read" "**/.env" = "deny" "**/.env.*" = "deny" Some limits. The docs only show globs as deny rules and directories as read entries, so this profile leaves single files like lockfiles to the backstop. The .env.* glob also hides .env.example, and an unbounded ** deny may need glob_scan_max_depth. The docs also describe profiles as the boundary for sandboxed commands, enforced by the OS sandbox, without spelling out Codex's own patch edits. Try a throwaway edit before you trust it. Use either profiles or sandbox_mode, not both. Antigravity CLI reads action(target) rules from one global file, ~/.gemini/antigravity-cli/settings.json. Deny beats ask beats allow, and paths are relative to the workspace roots (docs). The CLI docs describe no per-repo file, so these rules apply to every project you open: { "permissions": { "deny": [ "write_file(.github/)", "write_file(vendor/)", "write_file(src/gen/)", "write_file(package-lock.json)", "write_file(CHANGELOG.md)", "read_file(.env)" ] } } Gemini CLI was replaced by Antigravity CLI for unpaid-tier and Google One users on June 18, 2026 (Gemini CLI docs). If you still run it, its policy engine matches argsPattern as a regex against the tool's arguments serialised to JSON. Workspace policy files are disabled, so this goes in ~/.gemini/policies/agent-paths.toml. The edit tools are write_file and replace, and both take file_path (tools): [[rule]] toolName = ["write_file", "replace"] argsPattern = '"file_path":"[^"]*\.github/workflows/' decision = "deny" priority = 900 The backstop for agents with no deny syntax Some agents have no path rules, and every local rule can be edited by someone on the machine. The server doesn't care which tool wrote the commit. Own the risky paths, including the CODEOWNERS file itself, then turn on "Require review from Code Owners" in branch protection. Without that setting, owners are requested but approval isn't enforced (GitHub docs): # .github/CODEOWNERS /.github/ @acme/platform /db/migrate/ @acme/data /vendor/ @acme/platform /CHANGELOG.md @acme/release Repository rulesets add two more. "Restrict file paths" blocks pushes of commits that change matching paths, which suits .env files nobody should push; push rulesets work in private and internal repositories (About rulesets). "Required reviewers" asks specific teams to approve changes to file patterns, in organisation repos. On GitHub Enterprise Server, an enforced pre-receive hook rejects any push where its script exits non-zero. For migrations, fail the PR when an existing file changes. --diff-filter=MDR lists modified, deleted and renamed files and skips added ones: changed=$(git diff --name-only --diff-filter=MDR \ origin/main...HEAD -- db/migrate) if [ -n "$changed" ]; then echo "Applied migrations changed:" echo "$changed" exit 1 fi Where a slipped edit shows up in Hermes I build Hermes IDE, so weigh this with that in mind. It runs Claude Code, Codex, Antigravity CLI and other agents side by side, each task on its own hermes/ branch and worktree. A committed .claude/settings.json is a tracked file, so it is already in every task's worktree. Rules miss things: a script the agent runs can still write any of these files. The Review Desk (⌘G, Ctrl+Shift+G on Windows and Linux) shows an agent's changes by turn or by file, with risk flags. Don't count on a flag for every path in this list: open the by-file view and check the eight paths yourself. !The Hermes Review Desk in By turn view, listing two turns and showing turn 1's diff of src/db.ts with the changed-files list and a drafted commit message The Review Desk (⌘G) in By turn view for a Claude Code task in orders-api: two turns, turn 1's diff of src/db.ts, the changed-files list, and a commit message drafted from the turns. Every agent turn is saved, so when a turn touched one of those paths, you can diff that turn or restore it, with undo. A line comment goes back to the agent that wrote the line. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux: download it or read the source on GitHub. --- If this was useful Two of these rules, migrations and changelogs, rest on one idea: once history has shipped, you append to it and never rewrite it. That idea runs through the Event-Driven Architecture Pocket Guide, where event logs, outbox tables and sagas all depend on never editing what already happened. It's one of 8 short books in the Pocket Guides series, covering agents, RAG, prompts, observability, events, databases and system design. 
Compiling Inline Event Handlers (onclick) for Strict CSP in Astro (astro-strict-csp v0.2.0) Right after publishing v0.1.3 of astro-strict-csp to handle Shiki inline styles and <head> theme scripts, I remembered another annoying CSP workaround sitting in my own website's codebase: inline event attributes. When you enforce a strict Content Security Policy (script-src 'self' with zero 'unsafe-inline'), browsers don't just block <script> blocks—they also ruthlessly block every inline DOM event attribute like onclick, onmouseenter, or onsubmit. If you want a simple <button onclick="window.print()"> in a template or markdown file, strict CSP forces you to give it an ID, open a separate <script> tag, query the selector, and attach an addEventListener. It’s tedious boilerplate that ruins the joy of writing simple HTML. Since the whole point of building astro-strict-csp was to stop thinking about security workarounds across my projects, I moved this fix into the package and shipped v0.2.0. Now I can write lazy inline onclick attributes whenever I want and let the compiler turn them into strict-CSP-compliant, SRI-hashed bundles at build time. --- 1. The Build-Time Transformation: Zero 'unsafe-inline' Without Rewriting HTML Instead of forcing you to refactor components or weaken your policy with 'unsafe-hashes', astro-strict-csp extracts inline event handlers during astro:build:done and rewrites them into data-csp-on-* attributes. What you write in your Astro template or Markdown: <button onclick="window.print()" class="btn">Print Recipe</button> <a href="#" onclick="copyLink(this); return false;">Share</a> What actually ships in production HTML: <button data-csp-on-click="evt-a1b2c3" class="btn">Print Recipe</button> <a href="#" data-csp-on-click="evt-d4e5f6">Share</a> <!-- Automatically hashed by the SHA-384 SRI pipeline --> <script type="module" src="/_astro/events-8f92a1.js" integrity="sha384-..." crossorigin="anonymous"></script> Because the event extraction step runs right before the Subresource Integrity (SRI) stage in our single-pass in-memory pipeline, the generated /_astro/events-[hash].js bundle automatically gets a cryptographic sha384 integrity hash and is whitelisted in your Content Security Policy. --- 2. The Runtime Engine: Preserving Native Browser Semantics Extracting the string inside onclick="..." into an external file is harder than it looks because browsers give inline event attributes very specific execution semantics that standard addEventListener callbacks don't have out of the box: 1. this Binding: Inside onclick="copyLink(this)", this refers to the DOM element itself. 2. The Implicit event Argument: Inline handlers have access to a local event variable without declaring it. 3. return false Behavior: Returning false from an inline handler automatically calls event.preventDefault(). To mirror native browser behavior without attaching hundreds of individual listeners across the DOM, astro-strict-csp generates a single delegated dispatcher: // Generated in /_astro/events-[hash].js const HANDLERS = { 'evt-a1b2c3': function(event) { window.print(); }, 'evt-d4e5f6': function(event) { copyLink(this); return false; } }; ['click'].forEach(evt => { document.addEventListener(evt, e => { for (const el of (e.composedPath ? e.composedPath() : [])) { if (el.getAttribute) { const id = el.getAttribute('data-csp-on-' + evt); if (id && HANDLERS[id]) { // Mirrors native inline event semantics: // 1. `this` is bound to the element via .call(el, e) // 2. `event` is passed as the parameter // 3. `return false` prevents default browser action if (HANDLERS[id].call(el, e) === false) { e.preventDefault(); } break; } } } }, { capture: true }); }); --- 3. The { capture: true } Trick for Non-Bubbling Events Most naive event delegation scripts attach a normal bubbling listener to document and check e.target. That works fine for click or keydown, but it fails completely on non-bubbling events like: - focus and blur - mouseenter and mouseleave - load and error - scroll Because those events do not bubble up the DOM tree, a normal listener on document never hears them. However, per the W3C UI Events specification, even non-bubbling events still travel down the DOM tree during the Capture Phase before reaching their target. By registering the root listener with { capture: true } and walking e.composedPath(), our single delegated listener cleanly intercepts onfocus, onblur, and onmouseenter attributes across the entire document—including inside open Shadow DOM trees. And just like the CSS extractor in v0.1.0: - Global Deduplication: If 50 buttons across 20 pages use onclick="window.print()", the handler is hashed and emitted only once in HANDLERS. - Region Masking: Code blocks (<code>, <pre>), <textarea>, <script> strings, and HTML comments are masked prior to extraction, so tutorials discussing onclick="..." are never mutated. - Zero Dependencies: Still 0 external npm dependencies—pure native Node and Web APIs. --- 4. Ditching Custom CI Scripts for Google's release-please Alongside the v0.2.0 feature work, I cleaned up the repository's release pipeline. Previously, I used custom GitHub Actions workflows to handle version bumps and npm publishing. Custom release scripts always end up brittle—you forget to update CHANGELOG.md, mismatch a git tag, or fight token permissions. I replaced the custom CI logic with Google's release-please. Now, because the repo already follows strict Conventional Commits (feat:, fix:, perf:), release-please automatically maintains a living Release PR. Every time a commit lands on main, it updates the semantic version and compiles a categorized changelog. Merging that PR automatically tags the release and publishes the new tarball to npm with zero manual steps. --- Try It Out npm install astro-strict-csp@latest Links: - npm: npmjs.com - GitHub: Evgenii-Zinner/astro-strict-csp If you hit an edge case with a weird inline handler or want to request a feature, drop a bug report or feature request in the GitHub Issues.
PunyGuard: A Chrome Extension That Blocks Every Internationalized Domain аррӏө.com and apple.com. Put them side by side: can you tell which one is real? All five letters in the first one are Cyrillic. Even so, Chrome 154's address bar displayed it exactly as-is. It looks identical to apple.com (GIZMODO article). After reading that article, I built a small Chrome extension called PunyGuard. It blocks access to internationalized domain names (IDNs), all of them, no exceptions. It doesn't judge whether a domain looks similar to anything. Legitimate Japanese domains get blocked too. It may look like a heavy-handed tradeoff, but that tradeoff is the core of the design. Out of nowhere, a confession: I don't like Japanese domain names. Rather than getting sent to some Japanese-named site where I can't even tell who's behind it, I'd honestly prefer not to be able to reach it at all. That conviction is what the whole design rests on. So instead of making the machine do fuzzy judgment calls, I gave it a rule it can't get wrong. The machine handles the routine work; I decide what to protect and what to give up. It's a small extension, but the way I think about working with computers and AI shows up directly in both its design decisions and how I built it. sho7650/punyguard > ℹ️ If you use unbound (I do too), riorist's post Preventing homograph attacks with Unbound (in Japanese) is worth a look too. Since it works regardless of which browser you use, it's arguably the more secure option. Miss one character, and the look-alike detection falls apart Chrome has a mechanism that displays domains containing confusable characters in Punycode. Punycode is a scheme for rewriting domain names that contain non-ASCII characters (ASCII being half-width letters, digits, and symbols), like Japanese or Cyrillic, into an ASCII string starting with xn--. аррӏө.com becomes xn--80a6aa68c8d.com. If a suspicious domain is shown as that jumble of symbols, at least it won't look like apple.com. So what counts as "confusable"? The basis for that call is a list of look-alike characters. This time, ө (U+04E9) wasn't on the list. Because of that single missing character, five Cyrillic letters sat in the address bar looking exactly like the real thing. I don't think this is really anyone's mistake so much as a limit of the approach itself. The world has an enormous number of characters, and how they look changes from font to font. Exhaustively listing every "similar-looking character" is a job that never ends. However carefully you grow the list, the next ө will be hiding somewhere. Human eyes are the same. The assumption that you can tell them apart if you look carefully collapses instantly in the face of a one-character difference. So I decided to stop judging by appearance altogether. A design that stops judging All PunyGuard looks at is whether the hostname has a label starting with xn--. That's it. A label is each piece of www.example.com split by .. If even one label starts with xn--, the host is treated as an IDN host and blocked. However natural an internationalized domain looks on screen in the browser, on the wire it always carries the xn-- prefix. A gatekeeper who judges by faces is vulnerable to look-alikes. A gatekeeper who checks name tags doesn't hesitate, no matter how similar the faces are. I built PunyGuard to be the second kind of gatekeeper. Before settling on this policy, I considered other paths and left a decision record (ADR). One option was to draw the line by script: let kanji and kana through, block Cyrillic and so on. Its upside is that Japanese domains would get through. But even within the allowed scripts there are confusable characters, so it just moves the problem somewhere else. Another option was to judge by visual similarity, which is the same idea Chrome uses, and exactly the approach that let this one slip through. And as I said, I dislike Japanese domains. What I agonized over most was an allowlist and a "proceed just this once" button. They'd be convenient. But as long as the warning screen has a button to proceed, there's room for someone to sweet-talk you into pressing it. Phishing is, after all, also the art of getting people to press buttons. I decided to leave them out of v1, and recorded them in the ADR as candidates to add if they ever become truly necessary. The price I pay instead is clear. Legitimate Japanese domains like 日本語.jp get blocked too. Since I dislike Japanese domains myself, I judged that these false positives don't hurt me. For people who use Japanese domains, it's an inconvenient extension, and I said so near the top of the README. This is a tool for me, first and foremost. It's enough if the people who share my philosophy get it. Just one blocking rule The core of the implementation is Chrome's declarativeNetRequest (DNR from here on). An extension registers rules with Chrome along the lines of "treat requests matching this condition like this," and from then on Chrome applies them itself. The extension doesn't need to peek at every page the user visits. PunyGuard registers just one rule, and its condition is this regex: ^https?://(?:[^/?#]*[.@])?xn-- Reading from the start of the URL, it matches if any label in the hostname starts with xn--. It also blocks when the top-level domain is an IDN (e.g. example.xn--q9jyb4c). It doesn't match hosts that merely have xn-- in the middle of a label (axn--b.com), or xn-- appearing in the path or query. It applies to two things, top-level page navigations and pages embedded in another page (iframes), and doesn't affect loading of resources like images or scripts. DNR converts the hostname to Punycode before matching. So even if you type аррӏө.com into the address bar in Unicode, the same rule catches it. Clicking a link, typing in the address bar, a server-side redirect, a link opened from another app: whatever the path, the result is the same. The requirement I first wrote down was "if a URL contains anything other than English ASCII, refuse the click." While working out the plan, I realized I couldn't build it as written, and revised it in two directions. First, I narrowed the check to the hostname (FQDN) only. If paths were included, pages like ja.wikipedia.org/wiki/東京 would get blocked too, which would make it unusable. Besides, by the time a request goes out, Japanese in the path has already been converted into an alphanumeric sequence like %E6%9D%B1, so you can't even ask "does it contain non-ASCII?" Second, I widened it to block paths other than clicks. Behind the wording of the requirement was the actual goal: "don't let me get to a fake site." Rather than building it to the letter, I chose to build it to that goal. Why ask for one more permission? After blocking, what do you show the user? This is where I made one big decision. DNR has block, which simply stops the request, and redirect, which sends it somewhere else. With block, the permissions you request stay minimal. But what the user sees is Chrome's standard error screen, ERR_BLOCKED_BY_CLIENT. That gives no way to tell whether the network is acting up or an extension blocked it. Faced with an unexplained wall, some people would get confused and just remove the extension. So I went with redirect, sending the user to a warning page bundled with the extension. The catch is that redirect requires access to all sites (<all_urls>). Minimal permissions versus telling the user why it was blocked: I weighed the two and chose the latter. Access to all sites is on the larger end of extension permissions. That's exactly why I keep everything else doing as little as possible. There isn't a single program that touches the content of the page you're viewing (a content script). It makes no external requests and stores nothing. What it requests, what it doesn't do, and why are all written plainly in the README. An extension that can't explain why it needs such broad permissions deserves not to be trusted. The redirect URL includes the extension's ID, and the ID varies by environment. So instead of hard-coding the rule in a file, the extension builds and registers it when it starts up. It re-registers every time: on install and update, and on browser startup. Dynamically registered rules are supposed to persist across restarts, but I decided not to rely on that "supposed to." The warning page trusts nothing The warning page shows the original URL, the hostname in both Unicode and Punycode, and a list of the non-ASCII characters. For аррӏө.com, you can see that ө is Cyrillic U+04E9. It's a screen that reveals, character by character, an identity you can't see by looking. The display language follows the browser: Japanese for Chrome in Japanese, English otherwise. The original URL is passed in the fragment (the part after #) of the redirect URL. The thing I was careful about here was not trusting any input to this page at all. The warning page can be opened from anywhere on the web with any fragment attached. You have to build it assuming a malicious string will show up. All output to the screen goes through textContent, so there's no path for a string to be interpreted as HTML. If a URL comes in that can't be parsed, it shows only generic text, with no host information. The original URL isn't turned into a link, and there's no button to copy it either. I decided from the start not to build anything you could press by accident. On a smaller note, I tripped over the string :~:. Chrome strips the #:~:text= syntax, used to jump to a specific passage on a page, from the URL the page can read (location.hash). If the original URL happens to contain :~:, everything after it disappears. This was caught in a review by Codex, and I fixed it to read the URL from the navigation history, before the stripping happens. There are two buttons. The primary one, "Close this tab," is always shown. It's the action that best fits the goal of getting away from a suspicious link. The secondary one, "Go back to the previous page," is shown only when there's history. When the warning page appears inside an iframe, neither is shown. Closing the parent page's tab from inside an iframe goes beyond what the user intended. I thought about the buttons a lot. I wanted to get away with a single button, "go back if you can, close if you can't," but browsers have no way to accurately check whether there's cross-site history behind you. I also considered going back, waiting a bit, and closing the tab if the page was still there. But deciding based on a wait time risks closing the tab while it's still on its way back to a heavy page. Rather than break the tab with an inaccurate check, it's better to accept that there will be cases where it misses, and say so honestly. That's why I split it into two buttons. I also planned for cases where scripts don't run. If the redirect lands in an iframe whose embedder has disabled scripts with the sandbox setting, the warning page's JavaScript won't run. So the HTML ships with a fixed warning message, in both Japanese and English, from the start. Even without the details, the one line "PunyGuard blocked navigation to this page" gets through. One more thing: if the URL contains a password, as in https://user:password@..., it's removed both from the display and from the warning page's own address bar. Exposing a secret on screen as a result of blocking would defeat the purpose of a protective tool. That said, the other Shossan living in my head does feel like "you didn't have to go that far..." The scariest defense is the one that fails silently The thing I most want to avoid in a security tool is a state where it's broken and nobody notices. When it looks like it's working but actually isn't protecting anything. In developing PunyGuard, I spent a lot of time figuring out how to stamp out these "silent failures." The first pitfall was regex dialects. DNR regexes aren't interpreted by JavaScript's regex engine but by a separate one called RE2. RE2 doesn't support some constructs, like lookahead, and Chrome also rejects regexes that are too large. Meanwhile, ordinary unit tests run on JavaScript regexes. Even if you use something RE2 can't handle, the tests pass. Ship it like that, and rule registration fails, and nothing gets blocked. And nothing at all happens on screen. I considered testing with a package that lets you call RE2 from Node instead. But that package translates JavaScript-style syntax for RE2 before running it, so it even lets through syntax that Chrome would reject. No matter how much you check in a similar environment, you won't get the same answer as production. What I ended up choosing was to have Chrome itself try it. In end-to-end tests (E2E tests) that actually drive a browser, I load the extension into a real Chromium. Then I pass the exact rule I'm going to register to Chrome's isRegexSupported and have it answer whether it can be used. In the end, only production knows the answer. So run it in production. Even so, the chance of registration failing on a user's machine isn't zero. When that happens, a ! badge shows up on the toolbar icon. There was one more easy-to-miss hole. On Chrome's extensions page, users can restrict site access to "On click" or to specific sites. When they do, rule registration still succeeds, but only the redirect stops working. No error shows up anywhere. So I added a check for access to all sites, and if it's missing, the same ! appears. You can see the cause in the tooltip that appears when you hover over the icon. To keep a stale check result from overwriting the badge when access is toggled quickly, the checks run one at a time, in order. In a normal window, if there's no !, blocking is working. I wanted to make it unmistakable that the settings and state are correct. Say what it can't do PunyGuard has a few weaknesses I left in knowingly. One is a known false positive. If xn-- appears in the username part of a URL, as in https://xn--a@example.com/, it gets blocked even though the host is plain ASCII. Fixing it properly would take a pretty complex regex within RE2's constraints. I've never seen anyone open a URL like this, and the only harm is the blocker being wrong, so for now it stays as specified behavior, and there's a test confirming it. Another is the back button. If the warning page opens as the first page of a new tab, and you navigate elsewhere and then come back with the browser's back button, "Go back to the previous page" is shown, but pressing it does nothing. That's the price of "accepting cases where it misses" from earlier; even in this state, "Close this tab" works. What I struggled with most was incognito windows. Chrome doesn't run extensions in incognito windows unless the user turns on "Allow in Incognito." If it isn't allowed, IDN hosts aren't blocked, and the badge doesn't warn you either. Showing ! forever to someone who has deliberately chosen not to allow it would be unkind. Even when it is allowed, the bundled warning page can't be shown in incognito tabs. Navigation to IDN hosts is blocked, but the screen shows Chrome's ERR_BLOCKED_BY_CLIENT, and the address bar keeps the original URL, password and all. Switching the extension's operating mode might have made it possible to show the warning page. But that would mean reworking the foundation of the design, and all it would buy is the "show the reason" part, not the "block" part. Since blocking itself works, this time I only documented it in the README. Hand repetitive work to machines Once I actually built it, I spent more time on the machinery around PunyGuard than on PunyGuard itself. The development environment is generated from copier-ts7, a template for TypeScript 7 that I made. The Node.js and pnpm versions are pinned with a tool called Nix, so the same versions run both on my machine and in CI, which runs automated tests on GitHub. A single pnpm run check runs, in order, type checking, linting, format checking, and tests that require at least 80% coverage. Releases are handled by an automation tool called release-please. As long as commit messages follow a set format, every time a change lands on main, it automatically creates a PR with the next version and the changelog. Merging that PR creates a tag and a GitHub Release, and bumps the version in package.json and manifest.json. A workflow then builds the tagged commit and attaches punyguard-vX.Y.Z.zip to the Release. Handy. I always forget to bump version numbers, so most of the apps I publish manage their versions this way. What the human doesn't hand over when building with AI As has become my usual lately, I built PunyGuard together with Claude Code. Write a plan, write tests first, then implement, set up E2E tests, build the release pipeline. Most of the code writing and running was done by the AI. And for every PR, I had Codex review it. I've set up skills so all of this happens entirely within Claude Code. This combination is very effective, and not just for this app. The :~: URL truncation, tests and the shipped build loading different files, the warning page not opening in incognito windows: all of these were first found in Codex reviews. Separating the writer from the reader makes it less likely that both fall into the same assumptions and the same unnecessary biases. The logic is the same as asking someone else to review your code on a human team. On the other hand, there's one thing I didn't hand to the AI: making the decisions. I'm the one who decided to block legitimate Japanese domains too. The AI's reaction was along the lines of "surely Japanese is fine...", but as I keep saying, I dislike Japanese domains. Narrowing the check to hostnames, and deciding to handle the incognito problem in the README only, were also all my calls. The AI lays out the options and the cost of each, carefully. But which cost to accept can only be decided by a human who knows the circumstances of the people using it. I can say the pain of false positives is small precisely because I'm someone who dislikes Japanese domains. That's why I always write down what I decide. Decisions, their reasoning, and the options I considered and rejected go in ADRs. Definitions of terms go in a glossary (CONTEXT.md). The plan, progress, and deferred issues with their reasons go in the plan document (tasks/todo.md). When working with AI, conversation memory gets summarized mid-way or cut off across days. If I had to explain everything from scratch each time, the context wouldn't be enough, and neither would my patience. If it's written down, the AI can interpret it in the next session and start from the same premises. The same goes for me reading it back six months from now. I also kept the final check as a human job. Links opened from external apps, switching Chrome's display language, the badge when site access is restricted, browser restarts, incognito windows. These are hard to verify with automated tests, so I checked each one myself in a real Chrome. You must not trust AI-written code on the strength of AI-written tests alone. Only after checking with my own eyes can I look anyone in the eye and say "it works." AI lends you more hands and an extra pair of eyes. Deciding what to build, what to give up, and whether it really works is, for now, the human's role. Draw that line clearly, and you can hand work to AI with peace of mind. I didn't choose the path of better detection There was surely a path of making the detection smarter. Grow the list, refine the similarity calculation, keep adding exceptions. PunyGuard didn't take that path. I made one single decision, not to judge, and handed the job of upholding it to a machine that never gets tired. Choose simplicity that never hesitates over competing on cleverness. That's the distance I want to keep when working with computers and AI. I decided not to put PunyGuard on the Chrome Web Store. It's distributed only on GitHub. Grab punyguard-vX.Y.Z.zip from Releases, unzip it, turn on Developer mode at chrome://extensions, and load it, and it'll work. Please leave site access set to "On all sites." If you don't use Japanese domains and you're worried about phishing with look-alike domains, I'd be glad if you gave it a try. Why not the Web Store? It's something I made for myself, and I just didn't want to start fielding "please make it do this!!" requests from other people. That said, if you share Shossan's philosophy, feel free to use it. sho7650/punyguard