@mountainpass/addressr 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,12 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.ELASTIC_PORT = void 0;
6
+ exports.ES_LOCALITY_INDEX_NAME = exports.ELASTIC_PORT = void 0;
7
7
  exports.dropIndex = dropIndex;
8
+ exports.dropLocalityIndex = dropLocalityIndex;
8
9
  exports.esConnect = esConnect;
9
10
  exports.initIndex = initIndex;
11
+ exports.initLocalityIndex = initLocalityIndex;
10
12
  var _debug = _interopRequireDefault(require("debug"));
11
13
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
14
  const waitPort = require('wait-port');
@@ -14,6 +16,7 @@ const elasticsearch = require('@opensearch-project/opensearch');
14
16
  const logger = (0, _debug.default)('api');
15
17
  const error = (0, _debug.default)('error');
16
18
  const ES_INDEX_NAME = process.env.ES_INDEX_NAME || 'addressr';
19
+ const ES_LOCALITY_INDEX_NAME = exports.ES_LOCALITY_INDEX_NAME = `${ES_INDEX_NAME}-localities`;
17
20
  const ELASTIC_PORT = exports.ELASTIC_PORT = Number.parseInt(process.env.ELASTIC_PORT || '9200');
18
21
  const ELASTIC_HOST = process.env.ELASTIC_HOST || '127.0.0.1';
19
22
  const ELASTIC_USERNAME = process.env.ELASTIC_USERNAME || undefined;
@@ -164,6 +167,138 @@ async function initIndex(esClient, clear, synonyms) {
164
167
  });
165
168
  logger(`indexGetResult:\n${JSON.stringify(indexGetResult, undefined, 2)}`);
166
169
  }
170
+ async function dropLocalityIndex(esClient) {
171
+ let exists = await esClient.indices.exists({
172
+ index: ES_LOCALITY_INDEX_NAME
173
+ });
174
+ if (exists.body) {
175
+ const deleteIndexResult = await esClient.indices.delete({
176
+ index: ES_LOCALITY_INDEX_NAME
177
+ });
178
+ logger({
179
+ deleteIndexResult
180
+ });
181
+ }
182
+ }
183
+ async function initLocalityIndex(esClient, clear, synonyms) {
184
+ if (clear) {
185
+ await dropLocalityIndex(esClient);
186
+ }
187
+ const exists = await esClient.indices.exists({
188
+ index: ES_LOCALITY_INDEX_NAME
189
+ });
190
+ const indexBody = {
191
+ settings: {
192
+ index: {
193
+ analysis: {
194
+ filter: {
195
+ my_synonym_filter: {
196
+ type: 'synonym',
197
+ lenient: true,
198
+ synonyms: synonyms || []
199
+ },
200
+ comma_stripper: {
201
+ type: 'pattern_replace',
202
+ pattern: ',',
203
+ replacement: ''
204
+ }
205
+ },
206
+ analyzer: {
207
+ my_analyzer: {
208
+ tokenizer: 'whitecomma',
209
+ filter: ['uppercase', 'asciifolding', 'my_synonym_filter', 'comma_stripper', 'trim']
210
+ }
211
+ },
212
+ tokenizer: {
213
+ whitecomma: {
214
+ type: 'pattern',
215
+ pattern: String.raw`[\W,]+`,
216
+ lowercase: false
217
+ }
218
+ }
219
+ }
220
+ }
221
+ },
222
+ aliases: {},
223
+ mappings: {
224
+ properties: {
225
+ locality_name: {
226
+ type: 'text',
227
+ analyzer: 'my_analyzer',
228
+ fields: {
229
+ raw: {
230
+ type: 'keyword'
231
+ }
232
+ }
233
+ },
234
+ locality_class_code: {
235
+ type: 'keyword'
236
+ },
237
+ locality_class_name: {
238
+ type: 'keyword'
239
+ },
240
+ primary_postcode: {
241
+ type: 'keyword'
242
+ },
243
+ state_abbreviation: {
244
+ type: 'keyword'
245
+ },
246
+ state_name: {
247
+ type: 'keyword'
248
+ },
249
+ postcodes: {
250
+ type: 'keyword'
251
+ },
252
+ locality_pid: {
253
+ type: 'keyword'
254
+ }
255
+ }
256
+ }
257
+ };
258
+ if (exists.body) {
259
+ const indexCloseResult = await esClient.indices.close({
260
+ index: ES_LOCALITY_INDEX_NAME
261
+ });
262
+ logger({
263
+ indexCloseResult
264
+ });
265
+ const indexPutSettingsResult = await esClient.indices.putSettings({
266
+ index: ES_LOCALITY_INDEX_NAME,
267
+ body: indexBody
268
+ });
269
+ logger({
270
+ indexPutSettingsResult
271
+ });
272
+ const indexPutMappingResult = await esClient.indices.putMapping({
273
+ index: ES_LOCALITY_INDEX_NAME,
274
+ body: indexBody.mappings
275
+ });
276
+ logger({
277
+ indexPutMappingResult
278
+ });
279
+ const indexOpenResult = await esClient.indices.open({
280
+ index: ES_LOCALITY_INDEX_NAME
281
+ });
282
+ logger({
283
+ indexOpenResult
284
+ });
285
+ const refreshResult = await esClient.indices.refresh({
286
+ index: ES_LOCALITY_INDEX_NAME
287
+ });
288
+ logger({
289
+ refreshResult
290
+ });
291
+ } else {
292
+ logger(`creating index: ${ES_LOCALITY_INDEX_NAME}`);
293
+ const indexCreateResult = await esClient.indices.create({
294
+ index: ES_LOCALITY_INDEX_NAME,
295
+ body: indexBody
296
+ });
297
+ logger({
298
+ indexCreateResult
299
+ });
300
+ }
301
+ }
167
302
  async function esConnect(esport = ELASTIC_PORT, eshost = ELASTIC_HOST, interval = 1000, timeout = 0) {
168
303
  // we keep trying to connect, no matter what
169
304
 
@@ -8,9 +8,13 @@ exports.dropIndex = dropIndex;
8
8
  exports.fetchGnafFile = fetchGnafFile;
9
9
  exports.getAddress = getAddress;
10
10
  exports.getAddresses = getAddresses;
11
+ exports.getLocality = getLocality;
11
12
  exports.loadGnaf = loadGnaf;
12
13
  exports.mapAddressDetails = mapAddressDetails;
13
14
  exports.searchForAddress = searchForAddress;
15
+ exports.searchForLocality = searchForLocality;
16
+ exports.searchForPostcode = searchForPostcode;
17
+ exports.searchForState = searchForState;
14
18
  exports.setAddresses = setAddresses;
15
19
  exports.unzipFile = unzipFile;
16
20
  var _debug = _interopRequireDefault(require("debug"));
@@ -706,6 +710,13 @@ async function loadAddressDetails(file, expectedCount, context, {
706
710
  }
707
711
  const indexingBody = [];
708
712
  for (const row of chunk.data) {
713
+ // Accumulate postcodes per locality for the locality index
714
+ if (context.postcodesByLocality && row.LOCALITY_PID && row.POSTCODE) {
715
+ if (!context.postcodesByLocality[row.LOCALITY_PID]) {
716
+ context.postcodesByLocality[row.LOCALITY_PID] = new Set();
717
+ }
718
+ context.postcodesByLocality[row.LOCALITY_PID].add(row.POSTCODE);
719
+ }
709
720
  const item = mapAddressDetails(row, context, actualCount, expectedCount);
710
721
  items.push(item);
711
722
  actualCount += 1;
@@ -875,6 +886,132 @@ async function searchForAddress(searchString, p, pageSize = PAGE_SIZE) {
875
886
  logger('hits', JSON.stringify(searchResp.body.hits, undefined, 2));
876
887
  return searchResp;
877
888
  }
889
+ async function searchForLocality(searchString, p, pageSize = PAGE_SIZE) {
890
+ const searchResp = await globalThis.esClient.search({
891
+ index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
892
+ body: {
893
+ from: (p - 1 || 0) * pageSize,
894
+ size: pageSize,
895
+ query: {
896
+ bool: {
897
+ ...(searchString && {
898
+ should: [{
899
+ multi_match: {
900
+ fields: ['locality_name'],
901
+ query: searchString,
902
+ fuzziness: 'AUTO',
903
+ type: 'bool_prefix',
904
+ lenient: true,
905
+ operator: 'AND'
906
+ }
907
+ }, {
908
+ multi_match: {
909
+ fields: ['locality_name'],
910
+ query: searchString,
911
+ type: 'phrase_prefix',
912
+ lenient: true,
913
+ operator: 'AND'
914
+ }
915
+ }]
916
+ })
917
+ }
918
+ },
919
+ sort: ['_score', {
920
+ 'locality_name.raw': {
921
+ order: 'asc'
922
+ }
923
+ }]
924
+ }
925
+ });
926
+ logger('locality hits', JSON.stringify(searchResp.body.hits, undefined, 2));
927
+ return searchResp;
928
+ }
929
+ async function getLocality(pid) {
930
+ const resp = await globalThis.esClient.get({
931
+ index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
932
+ id: `/localities/${pid}`
933
+ });
934
+ return resp;
935
+ }
936
+ async function searchForPostcode(searchString) {
937
+ const searchResp = await globalThis.esClient.search({
938
+ index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
939
+ body: {
940
+ from: 0,
941
+ size: 0,
942
+ query: {
943
+ bool: {
944
+ filter: [{
945
+ prefix: {
946
+ postcodes: searchString
947
+ }
948
+ }]
949
+ }
950
+ },
951
+ aggs: {
952
+ postcodes: {
953
+ terms: {
954
+ field: 'postcodes',
955
+ size: 20
956
+ },
957
+ aggs: {
958
+ localities: {
959
+ terms: {
960
+ field: 'locality_name.raw',
961
+ size: 100
962
+ }
963
+ }
964
+ }
965
+ }
966
+ }
967
+ }
968
+ });
969
+ logger('postcode hits', JSON.stringify(searchResp.body.aggregations, undefined, 2));
970
+ return searchResp;
971
+ }
972
+ async function searchForState(searchString) {
973
+ const query = searchString ? {
974
+ bool: {
975
+ should: [{
976
+ prefix: {
977
+ state_abbreviation: searchString.toUpperCase()
978
+ }
979
+ }, {
980
+ wildcard: {
981
+ state_name: `*${searchString.toUpperCase()}*`
982
+ }
983
+ }]
984
+ }
985
+ } : {
986
+ match_all: {}
987
+ };
988
+ const searchResp = await globalThis.esClient.search({
989
+ index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
990
+ body: {
991
+ from: 0,
992
+ size: 0,
993
+ query,
994
+ aggs: {
995
+ states: {
996
+ terms: {
997
+ field: 'state_abbreviation',
998
+ size: 20
999
+ },
1000
+ aggs: {
1001
+ state_name: {
1002
+ terms: {
1003
+ field: 'state_name',
1004
+ size: 1
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ });
1012
+ logger('state hits', JSON.stringify(searchResp.body.aggregations, undefined, 2));
1013
+ return searchResp;
1014
+ }
878
1015
  async function sendIndexRequest(indexingBody, initialBackoff = Number.parseInt(process.env.ADDRESSR_INDEX_BACKOFF || '30000'), {
879
1016
  refresh = false
880
1017
  } = {}) {
@@ -1024,7 +1161,9 @@ async function loadGnafData(directory, {
1024
1161
  // throw new Error(`Cannot file '${countsFile}' or '${contentsFile}'`)
1025
1162
  // }
1026
1163
  }
1027
- const loadContext = {};
1164
+ const loadContext = {
1165
+ postcodesByLocality: {}
1166
+ };
1028
1167
  await loadAuthFiles(files, directory, loadContext, filesCounts);
1029
1168
  // loadContext now contains all the auth files, so we can build the synonyms
1030
1169
  const synonyms = buildSynonyms(loadContext);
@@ -1074,6 +1213,51 @@ async function loadGnafData(directory, {
1074
1213
  });
1075
1214
  }
1076
1215
  }
1216
+
1217
+ // Phase 2: Index localities (separate post-load phase)
1218
+ // Failures here must not affect address loading above
1219
+ try {
1220
+ await (0, _elasticsearch.initLocalityIndex)(globalThis.esClient, process.env.ES_CLEAR_INDEX || false, synonyms);
1221
+ const localityIndexingBody = [];
1222
+ for (const detailFile of addressDetailFiles) {
1223
+ const state = _nodePath.default.basename(detailFile, _nodePath.default.extname(detailFile)).replace(/_.*/, '');
1224
+ if (COVERED_STATES.length === 0 || COVERED_STATES.includes(state)) {
1225
+ const stateName = await loadState(files, directory, state);
1226
+ const localities = await loadLocality(files, directory, state);
1227
+ for (const l of localities) {
1228
+ if (l.LOCALITY_NAME === '') continue;
1229
+ localityIndexingBody.push({
1230
+ index: {
1231
+ _index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
1232
+ _id: `/localities/${l.LOCALITY_PID}`
1233
+ }
1234
+ });
1235
+ // Derive postcodes: prefer accumulated from ADDRESS_DETAIL,
1236
+ // fall back to PRIMARY_POSTCODE from LOCALITY table
1237
+ const accumulatedPostcodes = loadContext.postcodesByLocality[l.LOCALITY_PID];
1238
+ const postcodes = accumulatedPostcodes ? [...accumulatedPostcodes] : l.PRIMARY_POSTCODE ? [l.PRIMARY_POSTCODE] : [];
1239
+ localityIndexingBody.push({
1240
+ locality_name: l.LOCALITY_NAME,
1241
+ locality_class_code: l.LOCALITY_CLASS_CODE,
1242
+ locality_class_name: localityClassCodeToName(l.LOCALITY_CLASS_CODE, loadContext),
1243
+ primary_postcode: postcodes[0] || '',
1244
+ postcodes,
1245
+ state_abbreviation: state,
1246
+ state_name: stateName,
1247
+ locality_pid: l.LOCALITY_PID
1248
+ });
1249
+ }
1250
+ }
1251
+ }
1252
+ if (localityIndexingBody.length > 0) {
1253
+ await sendIndexRequest(localityIndexingBody, undefined, {
1254
+ refresh: true
1255
+ });
1256
+ }
1257
+ logger('Locality indexing complete');
1258
+ } catch (error_) {
1259
+ error('Locality indexing failed (address loading unaffected):', error_);
1260
+ }
1077
1261
  }
1078
1262
  async function fileExists(countsFile) {
1079
1263
  try {
@@ -107,6 +107,140 @@ function startRest2Server() {
107
107
  parameters: ['q']
108
108
  }]
109
109
  });
110
+ const localitiesType = waycharter.registerCollection({
111
+ itemPath: '/:pid',
112
+ itemLoader: async ({
113
+ pid
114
+ }) => {
115
+ const resp = await (0, _addressService.getLocality)(pid);
116
+ const source = resp.body._source;
117
+ const hash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(source)).digest('hex');
118
+ return {
119
+ body: source,
120
+ headers: {
121
+ etag: `"${_version.version}-${hash}"`,
122
+ 'cache-control': `public, max-age=${ONE_WEEK}`
123
+ },
124
+ status: 200
125
+ };
126
+ },
127
+ collectionPath: '/localities',
128
+ collectionLoader: async ({
129
+ page,
130
+ q
131
+ }) => {
132
+ if (q && q.length > 1) {
133
+ const foundLocalities = await (0, _addressService.searchForLocality)(q, page + 1, PAGE_SIZE);
134
+ const body = foundLocalities.body.hits.hits.map(h => {
135
+ return {
136
+ name: h._source.locality_name,
137
+ state: {
138
+ name: h._source.state_name,
139
+ abbreviation: h._source.state_abbreviation
140
+ },
141
+ ...(h._source.locality_class_code && {
142
+ class: {
143
+ code: h._source.locality_class_code,
144
+ name: h._source.locality_class_name
145
+ }
146
+ }),
147
+ ...(h._source.primary_postcode && {
148
+ postcode: h._source.primary_postcode
149
+ }),
150
+ score: h._score,
151
+ pid: h._id.replace('/localities/', '')
152
+ };
153
+ });
154
+ const responseHash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
155
+ return {
156
+ body,
157
+ hasMore: page < foundLocalities.body.hits.total.value / PAGE_SIZE - 1,
158
+ headers: {
159
+ etag: `"${_version.version}-${responseHash}"`,
160
+ 'cache-control': `public, max-age=${ONE_WEEK}`
161
+ }
162
+ };
163
+ } else {
164
+ return {
165
+ body: [],
166
+ hasMore: false,
167
+ headers: {
168
+ etag: `"${_version.version}"`,
169
+ 'cache-control': `public, max-age=${ONE_WEEK}`
170
+ }
171
+ };
172
+ }
173
+ },
174
+ filters: [{
175
+ rel: 'https://addressr.io/rels/locality-search',
176
+ parameters: ['q']
177
+ }]
178
+ });
179
+ const postcodesType = waycharter.registerCollection({
180
+ collectionPath: '/postcodes',
181
+ collectionLoader: async ({
182
+ q
183
+ }) => {
184
+ if (q && q.length > 2) {
185
+ const result = await (0, _addressService.searchForPostcode)(q);
186
+ const buckets = result.body.aggregations.postcodes.buckets;
187
+ const body = buckets.map(bucket => ({
188
+ postcode: bucket.key,
189
+ localities: bucket.localities.buckets.map(l => ({
190
+ name: l.key
191
+ }))
192
+ }));
193
+ const responseHash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
194
+ return {
195
+ body,
196
+ hasMore: false,
197
+ headers: {
198
+ etag: `"${_version.version}-${responseHash}"`,
199
+ 'cache-control': `public, max-age=${ONE_WEEK}`
200
+ }
201
+ };
202
+ } else {
203
+ return {
204
+ body: [],
205
+ hasMore: false,
206
+ headers: {
207
+ etag: `"${_version.version}"`,
208
+ 'cache-control': `public, max-age=${ONE_WEEK}`
209
+ }
210
+ };
211
+ }
212
+ },
213
+ filters: [{
214
+ rel: 'https://addressr.io/rels/postcode-search',
215
+ parameters: ['q']
216
+ }]
217
+ });
218
+ const statesType = waycharter.registerCollection({
219
+ collectionPath: '/states',
220
+ collectionLoader: async ({
221
+ q
222
+ }) => {
223
+ const result = await (0, _addressService.searchForState)(q && q.length > 1 ? q : undefined);
224
+ const buckets = result.body.aggregations.states.buckets;
225
+ const body = buckets.map(bucket => ({
226
+ abbreviation: bucket.key,
227
+ name: bucket.state_name.buckets[0]?.key || bucket.key
228
+ }));
229
+ const responseHash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
230
+ return {
231
+ body,
232
+ hasMore: false,
233
+ headers: {
234
+ etag: `"${_version.version}-${responseHash}"`,
235
+ 'cache-control': `public, max-age=${ONE_WEEK}`
236
+ }
237
+ };
238
+ },
239
+ filters: [{
240
+ rel: 'https://addressr.io/rels/state-search',
241
+ parameters: ['q']
242
+ }]
243
+ });
110
244
  waycharter.registerResourceType({
111
245
  path: '/health',
112
246
  loader: async () => {
@@ -127,7 +261,7 @@ function startRest2Server() {
127
261
  loader: async () => {
128
262
  return {
129
263
  body: {},
130
- links: [...addressesType.additionalPaths, {
264
+ links: [...addressesType.additionalPaths, ...localitiesType.additionalPaths, ...postcodesType.additionalPaths, ...statesType.additionalPaths, {
131
265
  rel: 'https://addressr.io/rels/health',
132
266
  uri: '/health'
133
267
  }],
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.version = void 0;
7
7
  // Generated by genversion.
8
- const version = exports.version = '2.0.4';
8
+ const version = exports.version = '2.1.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mountainpass/addressr",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "description": "Australian Address Validation, Search and Autocomplete",
5
5
  "author": {
6
6
  "name": "Mountain Pass",
@@ -1,82 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * c4-generate.mjs — Regenerate C4 architecture diagrams from source code.
5
- * Portable, self-contained (no npm deps). Run via: node c4-generate.mjs
6
- */
7
- "use strict";
8
-
9
- var _nodeFs = _interopRequireDefault(require("node:fs"));
10
- var _nodePath = _interopRequireDefault(require("node:path"));
11
- var _c4Lib = require("./c4-lib.mjs");
12
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
13
- const ROOT = process.cwd();
14
- const OUT_DIR = _nodePath.default.join(ROOT, "docs", "architecture", "generated");
15
- const OUT_JSON = _nodePath.default.join(OUT_DIR, "components.json");
16
- const OUT_MERMAID = _nodePath.default.join(OUT_DIR, "components.mmd");
17
- const C4_MODEL = _nodePath.default.join(ROOT, "docs", "architecture", "C4_MODEL.md");
18
- const C3_START = "<!-- c3:generated:start -->";
19
- const C3_END = "<!-- c3:generated:end -->";
20
- const C4_START = "<!-- c4:generated:start -->";
21
- const C4_END = "<!-- c4:generated:end -->";
22
- const C4_SCAFFOLD = `# C4 Architecture Model
23
-
24
- This repo uses a hybrid C4 approach:
25
- - C1/C2 are curated for intent and business context.
26
- - C3/C4 are generated from code to reduce drift.
27
-
28
- ## C3: Component View (Generated)
29
-
30
- ${C3_START}
31
-
32
- ${C3_END}
33
-
34
- ## C4: Code View (Generated)
35
-
36
- File-level dependency diagrams per component. Dashed arrows indicate cross-component imports. Grey nodes are external files.
37
-
38
- ${C4_START}
39
-
40
- ${C4_END}
41
-
42
- Regenerate: \`/c4\`
43
- Check freshness: \`/c4-check\`
44
- `;
45
- function inlineGenerated(startMarker, endMarker, content) {
46
- if (!_nodeFs.default.existsSync(C4_MODEL)) return;
47
- const doc = _nodeFs.default.readFileSync(C4_MODEL, "utf8");
48
- const startIdx = doc.indexOf(startMarker);
49
- const endIdx = doc.indexOf(endMarker);
50
- if (startIdx === -1 || endIdx === -1) return;
51
- const before = doc.slice(0, startIdx + startMarker.length);
52
- const after = doc.slice(endIdx);
53
- const updated = `${before}\n\n${content}\n\n${after}`;
54
- _nodeFs.default.writeFileSync(C4_MODEL, updated);
55
- }
56
- function main() {
57
- const srcRoot = (0, _c4Lib.detectSourceRoot)(ROOT);
58
- const model = (0, _c4Lib.buildModel)(srcRoot);
59
- const json = (0, _c4Lib.toJson)(model);
60
- const c3Mermaid = (0, _c4Lib.toC3Mermaid)(model);
61
- const c4Mermaid = (0, _c4Lib.toC4Mermaid)(model);
62
- _nodeFs.default.mkdirSync(OUT_DIR, {
63
- recursive: true
64
- });
65
-
66
- // Create scaffold if C4_MODEL.md doesn't exist
67
- if (!_nodeFs.default.existsSync(C4_MODEL)) {
68
- _nodeFs.default.mkdirSync(_nodePath.default.dirname(C4_MODEL), {
69
- recursive: true
70
- });
71
- _nodeFs.default.writeFileSync(C4_MODEL, C4_SCAFFOLD);
72
- }
73
- _nodeFs.default.writeFileSync(OUT_JSON, json);
74
- _nodeFs.default.writeFileSync(OUT_MERMAID, c3Mermaid);
75
- inlineGenerated(C3_START, C3_END, `\`\`\`mermaid\n${c3Mermaid.trimEnd()}\n\`\`\``);
76
- inlineGenerated(C4_START, C4_END, c4Mermaid);
77
- console.log("PASS: C4 artifacts generated:");
78
- console.log(`- ${_nodePath.default.relative(ROOT, OUT_JSON)}`);
79
- console.log(`- ${_nodePath.default.relative(ROOT, OUT_MERMAID)}`);
80
- console.log(`- ${_nodePath.default.relative(ROOT, C4_MODEL)} (C3 + C4 sections updated)`);
81
- }
82
- main();
@@ -1,250 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.buildModel = buildModel;
7
- exports.detectSourceRoot = detectSourceRoot;
8
- exports.toC3Mermaid = toC3Mermaid;
9
- exports.toC4Mermaid = toC4Mermaid;
10
- exports.toJson = toJson;
11
- var _nodeFs = _interopRequireDefault(require("node:fs"));
12
- var _nodePath = _interopRequireDefault(require("node:path"));
13
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
- /**
15
- * c4-lib.mjs — Portable C4 model builder (pure Node.js, no npm deps).
16
- * Shared by c4-generate.mjs and c4-check.mjs.
17
- */
18
-
19
- // ---------------------------------------------------------------------------
20
- // Source root detection
21
- // ---------------------------------------------------------------------------
22
-
23
- function detectSourceRoot(projectRoot) {
24
- // 1. Try tsconfig.json
25
- const tsconfigPath = _nodePath.default.join(projectRoot, "tsconfig.json");
26
- if (_nodeFs.default.existsSync(tsconfigPath)) {
27
- try {
28
- const raw = _nodeFs.default.readFileSync(tsconfigPath, "utf8");
29
- // Strip single-line comments for lenient JSON parse
30
- const stripped = raw.replace(/\/\/.*$/gm, "");
31
- const tsconfig = JSON.parse(stripped);
32
- const rootDir = tsconfig?.compilerOptions?.rootDir;
33
- if (rootDir) {
34
- const candidate = _nodePath.default.resolve(projectRoot, rootDir);
35
- if (_nodeFs.default.existsSync(candidate)) return candidate;
36
- }
37
- const includes = tsconfig?.include;
38
- if (Array.isArray(includes) && includes.length > 0) {
39
- // Strip glob suffixes like /**/*
40
- const first = includes[0].replace(/\/\*.*$/, "");
41
- const candidate = _nodePath.default.resolve(projectRoot, first);
42
- if (_nodeFs.default.existsSync(candidate)) return candidate;
43
- }
44
- } catch {
45
- // Fall through to probing
46
- }
47
- }
48
-
49
- // 2. Probe common directories
50
- for (const probe of ["app/src", "src", "lib"]) {
51
- const candidate = _nodePath.default.join(projectRoot, probe);
52
- if (_nodeFs.default.existsSync(candidate)) return candidate;
53
- }
54
-
55
- // 3. Fall back to project root
56
- const fallback = projectRoot;
57
-
58
- // 4. Verify .ts files exist somewhere
59
- if (!hasFilesWithExtension(fallback, ".ts")) {
60
- for (const [ext, lang] of [[".py", "Python"], [".go", "Go"], [".rs", "Rust"], [".java", "Java"]]) {
61
- if (hasFilesWithExtension(fallback, ext)) {
62
- throw new Error(`C4 generation does not yet support ${lang} projects`);
63
- }
64
- }
65
- throw new Error("No TypeScript source files found");
66
- }
67
- return fallback;
68
- }
69
- function hasFilesWithExtension(dir, ext) {
70
- try {
71
- const entries = _nodeFs.default.readdirSync(dir, {
72
- withFileTypes: true
73
- });
74
- for (const entry of entries) {
75
- const full = _nodePath.default.join(dir, entry.name);
76
- if (entry.isDirectory()) {
77
- if (hasFilesWithExtension(full, ext)) return true;
78
- } else if (entry.isFile() && entry.name.endsWith(ext)) {
79
- return true;
80
- }
81
- }
82
- } catch {
83
- // Directory not readable
84
- }
85
- return false;
86
- }
87
-
88
- // ---------------------------------------------------------------------------
89
- // File walking
90
- // ---------------------------------------------------------------------------
91
-
92
- function walk(dir, out) {
93
- const entries = _nodeFs.default.readdirSync(dir, {
94
- withFileTypes: true
95
- });
96
- for (const entry of entries) {
97
- const full = _nodePath.default.join(dir, entry.name);
98
- if (entry.isDirectory()) {
99
- walk(full, out);
100
- continue;
101
- }
102
- if (!entry.isFile()) continue;
103
- if (!entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) continue;
104
- out.push(full);
105
- }
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // Import parsing & resolution
110
- // ---------------------------------------------------------------------------
111
-
112
- function parseImports(text) {
113
- const specs = [];
114
- const importRe = /import\s+[^"']*?["']([^"']+)["']/g;
115
- const dynamicRe = /import\(\s*["']([^"']+)["']\s*\)/g;
116
- const requireRe = /require\(\s*["']([^"']+)["']\s*\)/g;
117
- let match;
118
- while ((match = importRe.exec(text)) !== null) specs.push(match[1]);
119
- while ((match = dynamicRe.exec(text)) !== null) specs.push(match[1]);
120
- while ((match = requireRe.exec(text)) !== null) specs.push(match[1]);
121
- return specs;
122
- }
123
- function resolveImport(fromFile, spec, srcRoot) {
124
- if (!spec.startsWith(".")) return null;
125
- const stripped = spec.replace(/\.js$/, "");
126
- const base = _nodePath.default.resolve(_nodePath.default.dirname(fromFile), stripped);
127
- const candidates = [base, `${base}.ts`, _nodePath.default.join(base, "index.ts")];
128
- for (const candidate of candidates) {
129
- if (_nodeFs.default.existsSync(candidate) && _nodeFs.default.statSync(candidate).isFile()) {
130
- return candidate;
131
- }
132
- }
133
- return null;
134
- }
135
-
136
- // ---------------------------------------------------------------------------
137
- // Model building
138
- // ---------------------------------------------------------------------------
139
-
140
- function relToSrc(absPath, srcRoot) {
141
- return _nodePath.default.relative(srcRoot, absPath).split(_nodePath.default.sep).join("/");
142
- }
143
- function componentIdForRel(rel) {
144
- const [first] = rel.split("/");
145
- if (!first || !rel.includes("/")) return "app";
146
- return first;
147
- }
148
- function buildModel(srcRoot) {
149
- const files = [];
150
- walk(srcRoot, files);
151
- const componentFiles = new Map();
152
- const dependencies = new Map();
153
- const fileDeps = [];
154
- for (const absFile of files) {
155
- const fromRel = relToSrc(absFile, srcRoot);
156
- const fromComp = componentIdForRel(fromRel);
157
- if (!componentFiles.has(fromComp)) componentFiles.set(fromComp, new Set());
158
- componentFiles.get(fromComp).add(fromRel);
159
- if (!dependencies.has(fromComp)) dependencies.set(fromComp, new Set());
160
- const text = _nodeFs.default.readFileSync(absFile, "utf8");
161
- const specs = parseImports(text);
162
- for (const spec of specs) {
163
- const resolved = resolveImport(absFile, spec, srcRoot);
164
- if (!resolved) continue;
165
- const toRel = relToSrc(resolved, srcRoot);
166
- const toComp = componentIdForRel(toRel);
167
- fileDeps.push({
168
- from: fromRel,
169
- to: toRel
170
- });
171
- if (toComp !== fromComp) dependencies.get(fromComp).add(toComp);
172
- }
173
- }
174
- const components = [...componentFiles.keys()].sort().map(id => ({
175
- id,
176
- name: id === "app" ? "app-entry" : id,
177
- kind: "generated",
178
- files: [...(componentFiles.get(id) || [])].sort(),
179
- depends_on: [...(dependencies.get(id) || [])].sort()
180
- }));
181
- return {
182
- generator_version: "1",
183
- source_root: _nodePath.default.relative(process.cwd(), srcRoot).split(_nodePath.default.sep).join("/") || ".",
184
- components,
185
- fileDeps
186
- };
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // Mermaid generation
191
- // ---------------------------------------------------------------------------
192
-
193
- function toC3Mermaid(model) {
194
- const lines = ["flowchart LR"];
195
- for (const component of model.components) {
196
- lines.push(` ${component.id}["${component.name}"]`);
197
- }
198
- for (const component of model.components) {
199
- for (const to of component.depends_on) {
200
- lines.push(` ${component.id} --> ${to}`);
201
- }
202
- }
203
- lines.push("");
204
- return `${lines.join("\n")}\n`;
205
- }
206
- function fileNodeId(relPath) {
207
- return relPath.replace(/[/.\\-]/g, "_").replace(/\.ts$/, "");
208
- }
209
- function fileLabel(relPath) {
210
- return _nodePath.default.basename(relPath, ".ts");
211
- }
212
- function toC4Mermaid(model) {
213
- const sections = [];
214
- for (const component of model.components) {
215
- const lines = ["flowchart LR"];
216
- const fileSet = new Set(component.files);
217
- for (const file of component.files) {
218
- lines.push(` ${fileNodeId(file)}["${fileLabel(file)}"]`);
219
- }
220
- const externalNodes = new Set();
221
- const edges = new Set();
222
- for (const dep of model.fileDeps) {
223
- if (!fileSet.has(dep.from)) continue;
224
- const edgeKey = `${dep.from}|${dep.to}`;
225
- if (edges.has(edgeKey)) continue;
226
- edges.add(edgeKey);
227
- if (fileSet.has(dep.to)) {
228
- lines.push(` ${fileNodeId(dep.from)} --> ${fileNodeId(dep.to)}`);
229
- } else {
230
- const toCompId = componentIdForRel(dep.to);
231
- const toComp = toCompId === "app" ? "app-entry" : toCompId;
232
- const extId = fileNodeId(dep.to);
233
- if (!externalNodes.has(dep.to)) {
234
- externalNodes.add(dep.to);
235
- lines.push(` ${extId}["${toComp}/${fileLabel(dep.to)}"]:::ext`);
236
- }
237
- lines.push(` ${fileNodeId(dep.from)} -.-> ${extId}`);
238
- }
239
- }
240
- if (externalNodes.size > 0) {
241
- lines.push(` classDef ext fill:#f0f0f0,stroke:#999,stroke-dasharray:5 5`);
242
- }
243
- lines.push("");
244
- sections.push(`### ${component.name}\n\n\`\`\`mermaid\n${lines.join("\n")}\n\`\`\``);
245
- }
246
- return sections.join("\n\n");
247
- }
248
- function toJson(model) {
249
- return `${JSON.stringify(model, null, 2)}\n`;
250
- }
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * c4-check.mjs — Check whether C4 architecture diagrams are up to date.
5
- * Portable, self-contained (no npm deps). Run via: node c4-check.mjs
6
- */
7
- "use strict";
8
-
9
- var _nodeFs = _interopRequireDefault(require("node:fs"));
10
- var _nodePath = _interopRequireDefault(require("node:path"));
11
- var _c4Lib = require("../../c4/scripts/c4-lib.mjs");
12
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
13
- const ROOT = process.cwd();
14
- const COMPONENTS_FILE = _nodePath.default.join(ROOT, "docs", "architecture", "generated", "components.json");
15
- const POLICY_FILE = _nodePath.default.join(ROOT, "governance", "architecture-conformance-policy.json");
16
- function main() {
17
- const srcRoot = (0, _c4Lib.detectSourceRoot)(ROOT);
18
- const model = (0, _c4Lib.buildModel)(srcRoot);
19
- const freshJson = (0, _c4Lib.toJson)(model);
20
- const failures = [];
21
-
22
- // 1. Compare JSON against existing components.json
23
- if (!_nodeFs.default.existsSync(COMPONENTS_FILE)) {
24
- failures.push("missing generated architecture model: docs/architecture/generated/components.json");
25
- } else {
26
- const existingJson = _nodeFs.default.readFileSync(COMPONENTS_FILE, "utf8");
27
- if (existingJson !== freshJson) {
28
- failures.push("C4 model is stale — run /c4 to regenerate");
29
- }
30
- }
31
-
32
- // 2. Conformance policy check (if policy file exists)
33
- if (_nodeFs.default.existsSync(POLICY_FILE)) {
34
- const policy = JSON.parse(_nodeFs.default.readFileSync(POLICY_FILE, "utf8"));
35
- const components = new Map();
36
- for (const component of model.components) {
37
- components.set(component.id, new Set(component.depends_on || []));
38
- }
39
- for (const id of policy.required_components || []) {
40
- if (!components.has(id)) {
41
- failures.push(`missing required component: ${id}`);
42
- }
43
- }
44
- for (const rule of policy.forbidden_dependencies || []) {
45
- const deps = components.get(rule.from);
46
- if (!deps) {
47
- failures.push(`forbidden dependency rule references unknown component: ${rule.from}`);
48
- continue;
49
- }
50
- if (deps.has(rule.to)) {
51
- failures.push(`forbidden dependency: ${rule.from} -> ${rule.to}${rule.reason ? ` (${rule.reason})` : ""}`);
52
- }
53
- }
54
- for (const rule of policy.required_dependencies || []) {
55
- const deps = components.get(rule.from);
56
- if (!deps) {
57
- failures.push(`required dependency rule references unknown component: ${rule.from}`);
58
- continue;
59
- }
60
- if (!deps.has(rule.to)) {
61
- failures.push(`missing required dependency: ${rule.from} -> ${rule.to}${rule.reason ? ` (${rule.reason})` : ""}`);
62
- }
63
- }
64
- }
65
- if (failures.length > 0) {
66
- console.error("FAIL: C4 architecture check:");
67
- for (const failure of failures) {
68
- console.error(`- ${failure}`);
69
- }
70
- process.exit(1);
71
- }
72
- console.log("PASS: C4 architecture diagrams are up to date.");
73
- }
74
- main();
@@ -1,191 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Converts an OWM (Online Wardley Mapping) file to SVG and PNG.
4
- *
5
- * Usage: node owm-to-svg.mjs [input.owm] [output.svg]
6
- *
7
- * Defaults:
8
- * input: docs/wardley-map.owm
9
- * output: docs/wardley-map.svg (+ .png via sips)
10
- */
11
- "use strict";
12
-
13
- var _fs = require("fs");
14
- var _child_process = require("child_process");
15
- var _path = require("path");
16
- const inputPath = (0, _path.resolve)(process.argv[2] || 'docs/wardley-map.owm');
17
- const outputSvg = (0, _path.resolve)(process.argv[3] || inputPath.replace(/\.owm$/, '.svg'));
18
- const outputPng = outputSvg.replace(/\.svg$/, '.png');
19
- const raw = (0, _fs.readFileSync)(inputPath, 'utf8');
20
- const lines = raw.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//'));
21
-
22
- // Parse
23
- let title = '';
24
- const components = [];
25
- const links = [];
26
- const evolves = [];
27
- for (const line of lines) {
28
- const titleMatch = line.match(/^title\s+(.+)/);
29
- if (titleMatch) {
30
- title = titleMatch[1];
31
- continue;
32
- }
33
- const anchorMatch = line.match(/^anchor\s+(.+?)\s+\[([0-9.]+),\s*([0-9.]+)\]/);
34
- if (anchorMatch) {
35
- components.push({
36
- name: anchorMatch[1],
37
- vis: parseFloat(anchorMatch[2]),
38
- evo: parseFloat(anchorMatch[3]),
39
- isAnchor: true
40
- });
41
- continue;
42
- }
43
- const compMatch = line.match(/^component\s+(.+?)\s+\[([0-9.]+),\s*([0-9.]+)\]/);
44
- if (compMatch) {
45
- components.push({
46
- name: compMatch[1],
47
- vis: parseFloat(compMatch[2]),
48
- evo: parseFloat(compMatch[3]),
49
- isAnchor: false
50
- });
51
- continue;
52
- }
53
- const evolveMatch = line.match(/^evolve\s+(.+?)\s+([0-9.]+)/);
54
- if (evolveMatch) {
55
- evolves.push({
56
- name: evolveMatch[1],
57
- targetEvo: parseFloat(evolveMatch[2])
58
- });
59
- continue;
60
- }
61
- const linkMatch = line.match(/^(.+?)->(.+)/);
62
- if (linkMatch) {
63
- links.push({
64
- from: linkMatch[1].trim(),
65
- to: linkMatch[2].trim()
66
- });
67
- continue;
68
- }
69
- }
70
-
71
- // Layout constants
72
- const W = 1200;
73
- const H = 800;
74
- const PAD_LEFT = 80;
75
- const PAD_RIGHT = 60;
76
- const PAD_TOP = 60;
77
- const PAD_BOTTOM = 80;
78
- const CHART_W = W - PAD_LEFT - PAD_RIGHT;
79
- const CHART_H = H - PAD_TOP - PAD_BOTTOM;
80
- function evoToX(evo) {
81
- return PAD_LEFT + evo * CHART_W;
82
- }
83
- function visToY(vis) {
84
- return PAD_TOP + (1 - vis) * CHART_H;
85
- }
86
-
87
- // Build SVG
88
- const svgParts = [];
89
- svgParts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">`);
90
- svgParts.push(`<defs>
91
- <marker id="evolve-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
92
- <polygon points="0,0 8,3 0,6" fill="#c44"/>
93
- </marker>
94
- </defs>`);
95
- svgParts.push(`<style>
96
- text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
97
- .title { font-size: 20px; font-weight: 700; }
98
- .axis-label { font-size: 12px; fill: #666; }
99
- .phase-label { font-size: 11px; fill: #999; }
100
- .comp-label { font-size: 13px; fill: #222; }
101
- .anchor-label { font-size: 13px; fill: #222; font-weight: 600; }
102
- .evolve-label { font-size: 11px; fill: #c44; }
103
- </style>`);
104
-
105
- // Background
106
- svgParts.push(`<rect width="${W}" height="${H}" fill="white"/>`);
107
-
108
- // Title
109
- svgParts.push(`<text x="${PAD_LEFT}" y="35" class="title">${title}</text>`);
110
-
111
- // Axes
112
- svgParts.push(`<line x1="${PAD_LEFT}" y1="${PAD_TOP}" x2="${PAD_LEFT}" y2="${H - PAD_BOTTOM}" stroke="#333" stroke-width="1.5"/>`);
113
- svgParts.push(`<line x1="${PAD_LEFT}" y1="${H - PAD_BOTTOM}" x2="${W - PAD_RIGHT}" y2="${H - PAD_BOTTOM}" stroke="#333" stroke-width="1.5"/>`);
114
-
115
- // Arrow on evolution axis
116
- const arrowX = W - PAD_RIGHT;
117
- const arrowY = H - PAD_BOTTOM;
118
- svgParts.push(`<polygon points="${arrowX},${arrowY} ${arrowX - 8},${arrowY - 4} ${arrowX - 8},${arrowY + 4}" fill="#333"/>`);
119
-
120
- // Axis titles
121
- svgParts.push(`<text x="${PAD_LEFT - 10}" y="${PAD_TOP + CHART_H / 2}" class="axis-label" transform="rotate(-90 ${PAD_LEFT - 10} ${PAD_TOP + CHART_H / 2})" text-anchor="middle">Value Chain</text>`);
122
- svgParts.push(`<text x="${PAD_LEFT + CHART_W / 2}" y="${H - 15}" class="axis-label" text-anchor="middle">Evolution</text>`);
123
-
124
- // Phase dividers and labels
125
- const phases = [{
126
- boundary: 0.17,
127
- label: 'Genesis'
128
- }, {
129
- boundary: 0.37,
130
- label: 'Custom-Built'
131
- }, {
132
- boundary: 0.63,
133
- label: 'Product'
134
- }, {
135
- boundary: 1.0,
136
- label: 'Commodity'
137
- }];
138
- let prevBound = 0;
139
- for (const phase of phases) {
140
- const midEvo = (prevBound + phase.boundary) / 2;
141
- svgParts.push(`<text x="${evoToX(midEvo)}" y="${H - PAD_BOTTOM + 25}" class="phase-label" text-anchor="middle">${phase.label}</text>`);
142
- if (phase.boundary < 1.0) {
143
- const dx = evoToX(phase.boundary);
144
- svgParts.push(`<line x1="${dx}" y1="${PAD_TOP}" x2="${dx}" y2="${H - PAD_BOTTOM}" stroke="#ddd" stroke-width="1" stroke-dasharray="4,4"/>`);
145
- }
146
- prevBound = phase.boundary;
147
- }
148
-
149
- // Links
150
- const compMap = new Map(components.map(c => [c.name, c]));
151
- for (const link of links) {
152
- const from = compMap.get(link.from);
153
- const to = compMap.get(link.to);
154
- if (!from || !to) continue;
155
- svgParts.push(`<line x1="${evoToX(from.evo)}" y1="${visToY(from.vis)}" x2="${evoToX(to.evo)}" y2="${visToY(to.vis)}" stroke="#aaa" stroke-width="1.5"/>`);
156
- }
157
-
158
- // Evolution arrows
159
- for (const ev of evolves) {
160
- const comp = compMap.get(ev.name);
161
- if (!comp) continue;
162
- const x1 = evoToX(comp.evo);
163
- const x2 = evoToX(ev.targetEvo);
164
- const y = visToY(comp.vis);
165
- svgParts.push(`<line x1="${x1 + 8}" y1="${y}" x2="${x2 - 2}" y2="${y}" stroke="#c44" stroke-width="2" stroke-dasharray="6,3" marker-end="url(#evolve-arrow)"/>`);
166
- svgParts.push(`<circle cx="${x2}" cy="${y}" r="5" fill="none" stroke="#c44" stroke-width="1.5" stroke-dasharray="3,2"/>`);
167
- }
168
-
169
- // Components
170
- for (const comp of components) {
171
- const cx = evoToX(comp.evo);
172
- const cy = visToY(comp.vis);
173
- const r = comp.isAnchor ? 0 : 6;
174
- const labelClass = comp.isAnchor ? 'anchor-label' : 'comp-label';
175
- if (!comp.isAnchor) {
176
- svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" stroke="#333" stroke-width="1.5"/>`);
177
- }
178
- svgParts.push(`<text x="${cx + 10}" y="${cy - 10}" class="${labelClass}">${comp.name}</text>`);
179
- }
180
- svgParts.push('</svg>');
181
- const svg = svgParts.join('\n');
182
- (0, _fs.writeFileSync)(outputSvg, svg);
183
- console.log(`SVG: ${outputSvg}`);
184
-
185
- // Convert to PNG via sips (macOS)
186
- try {
187
- (0, _child_process.execSync)(`sips -s format png "${outputSvg}" --out "${outputPng}" 2>/dev/null`);
188
- console.log(`PNG: ${outputPng}`);
189
- } catch {
190
- console.log('PNG: skipped (sips not available, macOS only)');
191
- }