# json-conventions — JSON ファイル整形、スキーマライフサイクル、prune scope

**Scope:** webform JSON (管理画面に貼る JSON、またはファイルで受け渡す JSON) の整形・正規化、Form.io スキーマと Notes アイテム間の永続化境界 (`WebFormRevisionJson` / `FieldJson` / 孤児アイテム自動削除 (prune) の挙動)。データマッピング自体は [data-binding.md](/docs/data-binding.md) を参照。

## 1. JSON ファイル肥大化を避けるためのインデント運用

webform JSON は **Notes RichText アイテム** (`WebFormFieldJson` 等) として保存されるため、ディスク上のサイズはそのままサーバー側のメモリ・I/O コストに直結する。Form.io コンポーネントが増えると指数的に深いネスト構造になりやすく、**インデントの空白だけで本体サイズの 80%超** を占めるケースがある。

### 1.1 症状の見分け方

実例: あるフォームの webform JSON が **3.10 MB** (26,595 行) まで肥大化した。分析結果:

| 指標 | 値 |
|---|---|
| 行あたり平均インデント幅 | 103 spaces |
| 最大インデント幅 | 179 spaces |
| **leading whitespace 占有率** | **84.0%** (約 2.73 MB / 3.10 MB) |
| 区切り文字 | `":  "` (key と value の間に **2 スペース**) |

このパターンを見たら **標準外の JSON フォーマッタ (一部のスクリプト環境の整形器) で round-trip された跡**。標準 JSON フォーマッタ (`JSON.stringify(obj, null, 2)` / Python `json.dumps(obj, indent=2)`) は出さない歪な形になっていて、編集効率・転送効率の両面で害がある。

### 1.2 アンチパターン: 標準外の JSON フォーマッタでの round-trip

JSON を一度オブジェクトに読み込み、言語付属の汎用シリアライザで書き戻すと、以下の歪みが入ることがある (Node.js の `JSON.stringify(obj, null, 2)` と Python の `json.dumps(obj, indent=2)` は起こさない):

1. `key`/`value` 区切りが `":  "` (スペース 2 つ) — JSON 標準は `": "` (1 つ)
2. ネスト深度ごとのインデント増分が不規則 (4 → 19 → 23 のように 15 飛んだりする)
3. UTF-16 LE BOM 付きで書き出される
4. 整数が `1.0` のような浮動小数に変わる

webform JSON の整形は Node.js か Python の標準シリアライザに統一する (§1.4)。

### 1.3 推奨フォーマット

- **インデント**: 2-space step、ネスト深度に比例
- **区切り**: `": "` (1 スペース)
- **エンコーディング**: UTF-8 (BOM なし)
- **改行**: LF 推奨

### 1.4 正規化レシピ (Node.js)

`JSON.parse` → `JSON.stringify(obj, null, 2)` で round-trip すれば標準形に揃う。全 key 保持を担保するため事前にキー数を比較する整合性チェック付きで実行する:

```javascript
// reindent-json.js
const fs = require('fs');
const path = process.argv[2];
if (!path) { console.error('usage: node reindent-json.js <file>'); process.exit(1); }

const before = fs.readFileSync(path, 'utf8');
const beforeBytes = Buffer.byteLength(before, 'utf8');
const obj = JSON.parse(before);
const after = JSON.stringify(obj, null, 2);
const afterBytes = Buffer.byteLength(after, 'utf8');

function countKeys(node) {
  if (Array.isArray(node)) return node.reduce((a, v) => a + countKeys(v), 0);
  if (node && typeof node === 'object') {
    let n = Object.keys(node).length;
    for (const k of Object.keys(node)) n += countKeys(node[k]);
    return n;
  }
  return 0;
}
const keysBefore = countKeys(obj);
const keysAfter = countKeys(JSON.parse(after));
if (keysBefore !== keysAfter) {
  console.error('KEY COUNT MISMATCH: before=' + keysBefore + ' after=' + keysAfter);
  process.exit(2);
}

const bak = path + '.bak';
if (!fs.existsSync(bak)) fs.writeFileSync(bak, before, 'utf8');
fs.writeFileSync(path, after, 'utf8');

console.log('before    : ' + beforeBytes.toLocaleString() + ' bytes');
console.log('after     : ' + afterBytes.toLocaleString() + ' bytes');
console.log('reduction : ' + ((1 - afterBytes/beforeBytes) * 100).toFixed(1) + '%');
console.log('keys      : ' + keysBefore + ' (preserved)');
```

`node reindent-json.js path/to/form.json` で実行。`.bak` は初回のみ自動生成、2 回目以降は上書きしないので git diff 取りやすい。実績: 前述の 3.10 MB フォームで **3,251,478 bytes → 958,187 bytes (▼70.5%)**、全 22,174 keys 保持。

### 1.5 編集時のチェックリスト

- [ ] フォームビルダーからエクスポートし直した後、ファイルで保管している JSON を更新するときはこのレシピで再正規化する
- [ ] 保存前にファイルサイズを確認 — 直前比でいきなり数倍に膨らんでいたら round-trip 事故を疑う
- [ ] `":  "` (2 スペース区切り) が残っていないか検索して確認

### 1.6 改行ゼロの 1 行 JSON は Node の parse→stringify round-trip で構造編集する

管理画面やフォームビルダーからエクスポートした webform JSON は **改行ゼロ・数十〜数百 KB の 1 行** になることが多い。行単位のエディタや AI ツールの部分置換は、一意な位置を取れず **構造編集に向かない**。

文字列の完全一致置換で済ませたくなるが、**同名キーが多数あるファイルでは位置指定が脆く**、`"placeholder": ""` のような定型ボイラープレートは数百箇所ヒットして事故る。

**推奨: Node で `JSON.parse` → オブジェクト操作 → `JSON.stringify(obj)` (第 2・3 引数なし)**。これらのファイルは元々 `JSON.stringify` 出力そのもの (キー間スペース無し) なので、**無変更 round-trip がバイト同一になる** = 意図した差分以外は 1 バイトも動かない。着手前に必ず確認する:

```javascript
const src = fs.readFileSync(p, 'utf8');
console.log('identical:', src === JSON.stringify(JSON.parse(src)));  // ← true なら安全
```

`false` (= インデント付き整形済み) なら §1.4 のレシピで 2-space に正規化してから通常の行単位編集に切り替える。

編集スクリプトに入れる安全策:

- **key で再帰探索して対象ノードを掴む** (`components` / `columns` / `rows` / `tabs` / `panels` を辿る)。文字列位置ではなくオブジェクト参照で操作するので同名キー衝突が原理的に起きない
- **事前条件を assert する** — 例 `if (emp.placeholder !== '') throw new Error(...)`。二重適用や想定外の既存値で黙って上書きするのを防ぐ
- **styleBlock 追記はマーカーコメントで冪等化** — `if (sb.content.indexOf(MARKER) !== -1) throw` で再実行時の重複追記を止める
- **書き出し前にバックアップを取る** (ファイルならコピー、管理画面側なら編集前にリビジョン保存)
- **適用後に「変わった leaf の数」を再帰 diff で数える** — 意図した件数と一致しなければ即ロールバック:

```javascript
const diffs=[];
(function walk(x,y,path){ if(x===y)return;
  if(typeof x!==typeof y||x===null||y===null||typeof x!=='object'){diffs.push(path);return;}
  for(const k of new Set([...Object.keys(x),...Object.keys(y)])) walk(x[k],y[k],path+'.'+k);
})(before, after, '');
console.log(diffs.length, diffs);   // 例: 2 ['...employeeNumberB.placeholder', '...styleBlock.content']
```

**改行コード**: styleBlock の `content` 等に複数行テキストを追記するときは、既存部分の改行コード (多くは `\r\n`) に揃える。混在しても CSS/JS の動作には影響しないが、formDesigner の TEXTAREA 編集で行崩れして見える。

### 1.7 削除すべき不要スキーマ要素 (補足)

インデント正規化で本体サイズが大幅に減った後でも、Form.io コンポーネント定義に **常に空のデフォルト値** が大量に含まれていることがある。代表例:

| プロパティ | 削除可能な条件 |
|---|---|
| `validate: {required:false, custom:"", customPrivate:false, strictDateValidation:false, multiple:false, unique:false, minLength:"", maxLength:"", pattern:""}` | バリデーション未設定なら丸ごと省略可 (Form.io が default 補完する) |
| `conditional: {show:null, when:null, eq:""}` | 条件表示未使用なら省略可 |
| `overlay: {style:"", left:"", top:"", width:"", height:""}` | 通常は不使用、丸ごと省略可 |
| `properties: {}` / `attributes: {}` / `addons: []` / `attrs: []` | 空のまま使うことが多い、省略しても挙動同じ |
| `placeholder: "" / prefix: "" / suffix: "" / errorLabel: "" / tooltip: ""` | 空文字列なら省略可 |
| `customClass: ""` | 未指定なら省略可 (styleBlock フックを使うときだけ値を入れる) |

ただし削除作業はリスクがあるので **1 セルで動作確認 → 範囲拡大** の順で進める。一括で削除して全コンポーネントが壊れる事故を避ける。Form.io バージョンによって default 補完の挙動が異なるケースもあるので、最低限の動作テスト (保存 / 再オープン / 計算式ロジック) は実施する。

## 2. WebFormRevisionJson と FieldJson の使い分け

WEBフォーム管理文書には 2 つの紛らわしい JSON フィールドがあり、用途が違う。名前が似ているため取り違えやすいので、必ず使い分けを意識する。

- **`WebFormRevisionJson`** = Form.io **component schema** (form definition、`data` は空)
  - `Formio.createForm(document.getElementById('formio'), schema, options)` の **第 2 引数 (= form definition)** として渡すのはこちら
  - component の `{type, key, components:[...]}` 構造を持つ
  - スキーマ走査 (component key 集合の取得など) に使う

- **`WebFormRevisionFieldJson`** = Form.io **submission data** (現在文書の data 入り、初期値テンプレート)
  - コアの URL defaults 同期処理で新規文書の submission data の seed として使われる
  - data の `{k_no: "...", remark: "..."}` 構造
  - data 同期や URL defaults の反映で使う

Notes Document 上は両方とも RichText アイテムとして保存される。

**判別の覚え方**: 「**Field** が付くほうが**値 (data)**」。schema は素の `WebFormRevisionJson`。`Formio.createForm` の第 2 引数や、スキーマ走査系の処理 (component key 集合の取得等) には必ず **`WebFormRevisionJson`** を渡す。FieldJson を渡すと、data の中身を component 定義と誤解釈して Edit Grid の空配列等で key が拾えず、後段の prune や URL defaults 処理が壊れる。

### 2.1 FieldJson は公開時プレビューの「評価済み」スナップショット — default 系は新規文書で再評価されない

formDesigner で公開すると、プレビューフォームの**ライブ data** がそのまま FieldJson として保存される。つまり `defaultDate: "moment()"` や `customDefaultValue` の**評価結果 (= 公開日の日付などの具体値) が焼き込まれる**。

新規文書はこのテンプレートを submission data の seed にする (コアが `form.submission = { data: ... }` として適用する)。Form.io は**値が既に存在すると default 系を適用しない**ため:

- **症状**: 「今日の日付が入るはずのフィールドが、最後にフォームを公開した日で固定される」。ブラウザキャッシュと誤診しやすい (`autocomplete="off"` は無関係 — あれはオートフィル対策)
- **対処**: 新規時に常に動的計算したい値は `calculateValue` + `cfGlobal.isNewNote` ガードで上書きする:

```javascript
"calculateValue": "if (typeof cfGlobal !== 'undefined' && cfGlobal.isNewNote) { value = moment().startOf('day').toISOString(); }"
```

- `typeof cfGlobal !== 'undefined'` — ビルダープレビュー (cfGlobal 未定義) での例外防止
- `startOf('day')` — 日内で値が安定し、calculateValue 再評価ごとに値が変わる→change→再評価のループ/dirty 連打を防ぐ
- `toISOString()` — seed に焼き込まれる値と同じ ISO (UTC Z) 形状。`displayInTimezone: "viewer"` で表示は viewer タイムゾーンの今日になる
- 既存文書・下書き再オープンは `isNewNote=false` で保存値を維持

**単一行 JSON 編集時の重複キー罠**: Form.io の定型 schema はコンポーネント後方に `"customDefaultValue":"","calculateValue":"","calculateServer":false` の空ボイラープレートを既に持っていることが多い。別の位置に `"calculateValue":"..."` を**新規挿入すると重複キーになり、JSON.parse は後勝ちで空文字が有効化**され修正が無効になる。必ず既存の空キーを書き換えること (編集後は parse して effective 値を確認する)。

## 3. 孤児アイテム自動削除 (prune) は prefix 空時に schema/Data 由来 referencedKeys で managed scope 判定

コアには Notes 文書保存時に「webform スキーマ管理下にない孤児アイテム」を削除する prune 処理がある。挙動は `webFormNotesFieldPrefix` (典型的には `CF_`) の有無で切り替わる:

- **prefix 非空 (例: `CF_`)**: `name.startsWith(prefix)` で managed scope を判定 (`CF_` で始まるアイテムだけが管理対象)
- **prefix 空**: 保存処理が構築する `referencedKeys` (= incoming Data JSON + 前回 FieldJson + master schema 由来) に該当するキーだけが managed scope

**安全側の帰結**: prefix 空でもシステムフィールド (`Form`, `ApplicationNo`, `ApproversJson`, `RevisionNo`, `StatusNo`, `_*` プレフィックス等) は Form.io スキーマに含まれないため managed scope 外 → **prune で削除されない** (= 保護される)。

**危険側の帰結**: 同じ理由で、URL や外部経路で勝手に書き込まれた **非スキーマアイテム (例: `?Form=XXX` のような URL クエリ由来)** も managed scope 外 → **prune で削除されず汚染値が永続化する**。逆に、`CF_<key>` 形式でも `<key>` がスキーマに無ければ次回 save で prune に巻き込まれて消える。

**設計上の意味と運用**:

- URL クエリ / 外部経路 / カスタムエージェントから直接 `CF_<key>` を書く場合、**書き込み時点で `<key>` がスキーマに存在することを whitelist 確認**する。そうしないと:
  - スキーマに無い `<key>` を書いた場合 → 次回 save で prune に消える
  - スキーマには無いがシステムフィールド名 (例: `Form`, `StatusNo`) を書き換えた場合 → 永続化してしまい、システムフィールド書き換え攻撃が成立する
- コアの URL パラメータ初期化パスではスキーマキーのホワイトリストを適用済み。同様の外部経路を新設するときも同じ対策を入れる
- スキーマ外メタ情報を一時保持したいなら、`CF_<key>` ではなく `_*` プレフィックス (システム保護対象) または別経路 (専用アイテムや別 Notes 文書) を使う
- オブジェクト値コンポーネント (selectboxes / `valueProperty:""` の select / container 等) の期待名は `<prefix><key>` (グリッド内なら `<prefix><key><postfix>N`) の **1 個だけ**。旧コアの子キー平坦化アイテム (`CF_label` / `CF_<選択肢>`) は期待名に含まれないので、最新版への更新後の初回保存 (または管理者による再射影) で prune される ([data-binding.md](/docs/data-binding.md) §1)

画面表示パス (Notes→Form.io 逆同期) は schema-driven なので汚染アイテムは画面には現れないが、Notes 文書には残り続ける点に注意。

## Red Flags

- ❌ 標準外の JSON フォーマッタで webform JSON を round-trip → インデントが歪み、ファイルサイズが 3 倍以上に膨らむ。Node.js `JSON.stringify(obj, null, 2)` を使え (§1.2)
- ❌ `Formio.createForm(el, fieldJson)` のように Field 付き JSON を schema として渡す → data を schema として誤解釈。**WebFormRevisionJson** を渡せ (§2)
- ❌ 「今日の日付」等の動的初期値を `defaultDate`/`customDefaultValue` だけに頼る → 公開時に評価値が FieldJson に焼き込まれ、新規文書で**公開日のまま凍結**。`calculateValue` + `isNewNote` ガード (§2.1)
- ❌ 単一行 JSON にプロパティを新規挿入 → コンポーネント内の既存同名キーと**重複して後勝ちで無効化**。既存の空キーを書き換える (§2.1)
- ❌ URL/外部経路から `CF_<schemaにない key>` を書き込む → 次回 save で prune に消される。書き込み時に schema-key whitelist 必須 (§3)
- ❌ 再エクスポート後の JSON で行数が突然 3 倍になっている → フォーマッタ事故。§1.4 のレシピで再正規化
- ❌ 改行ゼロ 1 行 JSON を文字列の完全一致置換で編集 → 定型ボイラープレート (`"placeholder": ""` 等) が数百箇所ヒットして誤爆。Node の `JSON.parse` → key 再帰探索 → `JSON.stringify(obj)` round-trip で構造編集する (§1.6)
