@mountainpass/addressr 2.0.4 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client/elasticsearch.js +136 -1
- package/lib/service/address-service.js +252 -1
- package/lib/src/waycharter-server.js +172 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/lib/.claude/skills/c4/scripts/c4-generate.js +0 -82
- package/lib/.claude/skills/c4/scripts/c4-lib.js +0 -250
- package/lib/.claude/skills/c4-check/scripts/c4-check.js +0 -74
- package/lib/.claude/skills/wardley/owm-to-svg.js +0 -191
|
@@ -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,15 @@ exports.dropIndex = dropIndex;
|
|
|
8
8
|
exports.fetchGnafFile = fetchGnafFile;
|
|
9
9
|
exports.getAddress = getAddress;
|
|
10
10
|
exports.getAddresses = getAddresses;
|
|
11
|
+
exports.getLocality = getLocality;
|
|
12
|
+
exports.getPostcode = getPostcode;
|
|
13
|
+
exports.getState = getState;
|
|
11
14
|
exports.loadGnaf = loadGnaf;
|
|
12
15
|
exports.mapAddressDetails = mapAddressDetails;
|
|
13
16
|
exports.searchForAddress = searchForAddress;
|
|
17
|
+
exports.searchForLocality = searchForLocality;
|
|
18
|
+
exports.searchForPostcode = searchForPostcode;
|
|
19
|
+
exports.searchForState = searchForState;
|
|
14
20
|
exports.setAddresses = setAddresses;
|
|
15
21
|
exports.unzipFile = unzipFile;
|
|
16
22
|
var _debug = _interopRequireDefault(require("debug"));
|
|
@@ -706,6 +712,13 @@ async function loadAddressDetails(file, expectedCount, context, {
|
|
|
706
712
|
}
|
|
707
713
|
const indexingBody = [];
|
|
708
714
|
for (const row of chunk.data) {
|
|
715
|
+
// Accumulate postcodes per locality for the locality index
|
|
716
|
+
if (context.postcodesByLocality && row.LOCALITY_PID && row.POSTCODE) {
|
|
717
|
+
if (!context.postcodesByLocality[row.LOCALITY_PID]) {
|
|
718
|
+
context.postcodesByLocality[row.LOCALITY_PID] = new Set();
|
|
719
|
+
}
|
|
720
|
+
context.postcodesByLocality[row.LOCALITY_PID].add(row.POSTCODE);
|
|
721
|
+
}
|
|
709
722
|
const item = mapAddressDetails(row, context, actualCount, expectedCount);
|
|
710
723
|
items.push(item);
|
|
711
724
|
actualCount += 1;
|
|
@@ -875,6 +888,197 @@ async function searchForAddress(searchString, p, pageSize = PAGE_SIZE) {
|
|
|
875
888
|
logger('hits', JSON.stringify(searchResp.body.hits, undefined, 2));
|
|
876
889
|
return searchResp;
|
|
877
890
|
}
|
|
891
|
+
async function searchForLocality(searchString, p, pageSize = PAGE_SIZE) {
|
|
892
|
+
const searchResp = await globalThis.esClient.search({
|
|
893
|
+
index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
894
|
+
body: {
|
|
895
|
+
from: (p - 1 || 0) * pageSize,
|
|
896
|
+
size: pageSize,
|
|
897
|
+
query: {
|
|
898
|
+
bool: {
|
|
899
|
+
...(searchString && {
|
|
900
|
+
should: [{
|
|
901
|
+
multi_match: {
|
|
902
|
+
fields: ['locality_name'],
|
|
903
|
+
query: searchString,
|
|
904
|
+
fuzziness: 'AUTO',
|
|
905
|
+
type: 'bool_prefix',
|
|
906
|
+
lenient: true,
|
|
907
|
+
operator: 'AND'
|
|
908
|
+
}
|
|
909
|
+
}, {
|
|
910
|
+
multi_match: {
|
|
911
|
+
fields: ['locality_name'],
|
|
912
|
+
query: searchString,
|
|
913
|
+
type: 'phrase_prefix',
|
|
914
|
+
lenient: true,
|
|
915
|
+
operator: 'AND'
|
|
916
|
+
}
|
|
917
|
+
}]
|
|
918
|
+
})
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
sort: ['_score', {
|
|
922
|
+
'locality_name.raw': {
|
|
923
|
+
order: 'asc'
|
|
924
|
+
}
|
|
925
|
+
}]
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
logger('locality hits', JSON.stringify(searchResp.body.hits, undefined, 2));
|
|
929
|
+
return searchResp;
|
|
930
|
+
}
|
|
931
|
+
async function getLocality(pid) {
|
|
932
|
+
const resp = await globalThis.esClient.get({
|
|
933
|
+
index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
934
|
+
id: `/localities/${pid}`
|
|
935
|
+
});
|
|
936
|
+
return resp;
|
|
937
|
+
}
|
|
938
|
+
async function searchForPostcode(searchString) {
|
|
939
|
+
const query = searchString && searchString.length > 0 ? {
|
|
940
|
+
bool: {
|
|
941
|
+
filter: [{
|
|
942
|
+
prefix: {
|
|
943
|
+
postcodes: searchString
|
|
944
|
+
}
|
|
945
|
+
}]
|
|
946
|
+
}
|
|
947
|
+
} : {
|
|
948
|
+
bool: {
|
|
949
|
+
filter: [{
|
|
950
|
+
exists: {
|
|
951
|
+
field: 'postcodes'
|
|
952
|
+
}
|
|
953
|
+
}]
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
const searchResp = await globalThis.esClient.search({
|
|
957
|
+
index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
958
|
+
body: {
|
|
959
|
+
from: 0,
|
|
960
|
+
size: 0,
|
|
961
|
+
query,
|
|
962
|
+
aggs: {
|
|
963
|
+
postcodes: {
|
|
964
|
+
terms: {
|
|
965
|
+
field: 'postcodes',
|
|
966
|
+
size: 20
|
|
967
|
+
},
|
|
968
|
+
aggs: {
|
|
969
|
+
localities: {
|
|
970
|
+
terms: {
|
|
971
|
+
field: 'locality_name.raw',
|
|
972
|
+
size: 100
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
logger('postcode hits', JSON.stringify(searchResp.body.aggregations, undefined, 2));
|
|
981
|
+
return searchResp;
|
|
982
|
+
}
|
|
983
|
+
async function getPostcode(postcode) {
|
|
984
|
+
const searchResp = await globalThis.esClient.search({
|
|
985
|
+
index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
986
|
+
body: {
|
|
987
|
+
size: 0,
|
|
988
|
+
query: {
|
|
989
|
+
term: {
|
|
990
|
+
postcodes: postcode
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
aggs: {
|
|
994
|
+
localities: {
|
|
995
|
+
terms: {
|
|
996
|
+
field: 'locality_name.raw',
|
|
997
|
+
size: 100
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
return searchResp;
|
|
1004
|
+
}
|
|
1005
|
+
async function getState(abbreviation) {
|
|
1006
|
+
const searchResp = await globalThis.esClient.search({
|
|
1007
|
+
index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
1008
|
+
body: {
|
|
1009
|
+
size: 0,
|
|
1010
|
+
query: {
|
|
1011
|
+
term: {
|
|
1012
|
+
state_abbreviation: abbreviation.toUpperCase()
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
aggs: {
|
|
1016
|
+
state_name: {
|
|
1017
|
+
terms: {
|
|
1018
|
+
field: 'state_name',
|
|
1019
|
+
size: 1
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
localities: {
|
|
1023
|
+
terms: {
|
|
1024
|
+
field: 'locality_name.raw',
|
|
1025
|
+
size: 1000
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
postcodes: {
|
|
1029
|
+
terms: {
|
|
1030
|
+
field: 'postcodes',
|
|
1031
|
+
size: 1000
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
return searchResp;
|
|
1038
|
+
}
|
|
1039
|
+
async function searchForState(searchString) {
|
|
1040
|
+
const query = searchString ? {
|
|
1041
|
+
bool: {
|
|
1042
|
+
should: [{
|
|
1043
|
+
prefix: {
|
|
1044
|
+
state_abbreviation: searchString.toUpperCase()
|
|
1045
|
+
}
|
|
1046
|
+
}, {
|
|
1047
|
+
wildcard: {
|
|
1048
|
+
state_name: `*${searchString.toUpperCase()}*`
|
|
1049
|
+
}
|
|
1050
|
+
}]
|
|
1051
|
+
}
|
|
1052
|
+
} : {
|
|
1053
|
+
match_all: {}
|
|
1054
|
+
};
|
|
1055
|
+
const searchResp = await globalThis.esClient.search({
|
|
1056
|
+
index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
1057
|
+
body: {
|
|
1058
|
+
from: 0,
|
|
1059
|
+
size: 0,
|
|
1060
|
+
query,
|
|
1061
|
+
aggs: {
|
|
1062
|
+
states: {
|
|
1063
|
+
terms: {
|
|
1064
|
+
field: 'state_abbreviation',
|
|
1065
|
+
size: 20
|
|
1066
|
+
},
|
|
1067
|
+
aggs: {
|
|
1068
|
+
state_name: {
|
|
1069
|
+
terms: {
|
|
1070
|
+
field: 'state_name',
|
|
1071
|
+
size: 1
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
logger('state hits', JSON.stringify(searchResp.body.aggregations, undefined, 2));
|
|
1080
|
+
return searchResp;
|
|
1081
|
+
}
|
|
878
1082
|
async function sendIndexRequest(indexingBody, initialBackoff = Number.parseInt(process.env.ADDRESSR_INDEX_BACKOFF || '30000'), {
|
|
879
1083
|
refresh = false
|
|
880
1084
|
} = {}) {
|
|
@@ -1024,7 +1228,9 @@ async function loadGnafData(directory, {
|
|
|
1024
1228
|
// throw new Error(`Cannot file '${countsFile}' or '${contentsFile}'`)
|
|
1025
1229
|
// }
|
|
1026
1230
|
}
|
|
1027
|
-
const loadContext = {
|
|
1231
|
+
const loadContext = {
|
|
1232
|
+
postcodesByLocality: {}
|
|
1233
|
+
};
|
|
1028
1234
|
await loadAuthFiles(files, directory, loadContext, filesCounts);
|
|
1029
1235
|
// loadContext now contains all the auth files, so we can build the synonyms
|
|
1030
1236
|
const synonyms = buildSynonyms(loadContext);
|
|
@@ -1074,6 +1280,51 @@ async function loadGnafData(directory, {
|
|
|
1074
1280
|
});
|
|
1075
1281
|
}
|
|
1076
1282
|
}
|
|
1283
|
+
|
|
1284
|
+
// Phase 2: Index localities (separate post-load phase)
|
|
1285
|
+
// Failures here must not affect address loading above
|
|
1286
|
+
try {
|
|
1287
|
+
await (0, _elasticsearch.initLocalityIndex)(globalThis.esClient, process.env.ES_CLEAR_INDEX || false, synonyms);
|
|
1288
|
+
const localityIndexingBody = [];
|
|
1289
|
+
for (const detailFile of addressDetailFiles) {
|
|
1290
|
+
const state = _nodePath.default.basename(detailFile, _nodePath.default.extname(detailFile)).replace(/_.*/, '');
|
|
1291
|
+
if (COVERED_STATES.length === 0 || COVERED_STATES.includes(state)) {
|
|
1292
|
+
const stateName = await loadState(files, directory, state);
|
|
1293
|
+
const localities = await loadLocality(files, directory, state);
|
|
1294
|
+
for (const l of localities) {
|
|
1295
|
+
if (l.LOCALITY_NAME === '') continue;
|
|
1296
|
+
localityIndexingBody.push({
|
|
1297
|
+
index: {
|
|
1298
|
+
_index: _elasticsearch.ES_LOCALITY_INDEX_NAME,
|
|
1299
|
+
_id: `/localities/${l.LOCALITY_PID}`
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
// Derive postcodes: prefer accumulated from ADDRESS_DETAIL,
|
|
1303
|
+
// fall back to PRIMARY_POSTCODE from LOCALITY table
|
|
1304
|
+
const accumulatedPostcodes = loadContext.postcodesByLocality[l.LOCALITY_PID];
|
|
1305
|
+
const postcodes = accumulatedPostcodes ? [...accumulatedPostcodes] : l.PRIMARY_POSTCODE ? [l.PRIMARY_POSTCODE] : [];
|
|
1306
|
+
localityIndexingBody.push({
|
|
1307
|
+
locality_name: l.LOCALITY_NAME,
|
|
1308
|
+
locality_class_code: l.LOCALITY_CLASS_CODE,
|
|
1309
|
+
locality_class_name: localityClassCodeToName(l.LOCALITY_CLASS_CODE, loadContext),
|
|
1310
|
+
primary_postcode: postcodes[0] || '',
|
|
1311
|
+
postcodes,
|
|
1312
|
+
state_abbreviation: state,
|
|
1313
|
+
state_name: stateName,
|
|
1314
|
+
locality_pid: l.LOCALITY_PID
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (localityIndexingBody.length > 0) {
|
|
1320
|
+
await sendIndexRequest(localityIndexingBody, undefined, {
|
|
1321
|
+
refresh: true
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
logger('Locality indexing complete');
|
|
1325
|
+
} catch (error_) {
|
|
1326
|
+
error('Locality indexing failed (address loading unaffected):', error_);
|
|
1327
|
+
}
|
|
1077
1328
|
}
|
|
1078
1329
|
async function fileExists(countsFile) {
|
|
1079
1330
|
try {
|
|
@@ -107,6 +107,177 @@ 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
|
+
itemPath: '/:postcode',
|
|
181
|
+
itemLoader: async ({
|
|
182
|
+
postcode
|
|
183
|
+
}) => {
|
|
184
|
+
const result = await (0, _addressService.getPostcode)(postcode);
|
|
185
|
+
const localities = result.body.aggregations.localities.buckets.map(l => ({
|
|
186
|
+
name: l.key
|
|
187
|
+
}));
|
|
188
|
+
const body = {
|
|
189
|
+
postcode,
|
|
190
|
+
localities
|
|
191
|
+
};
|
|
192
|
+
const hash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
|
|
193
|
+
return {
|
|
194
|
+
body,
|
|
195
|
+
headers: {
|
|
196
|
+
etag: `"${_version.version}-${hash}"`,
|
|
197
|
+
'cache-control': `public, max-age=${ONE_WEEK}`
|
|
198
|
+
},
|
|
199
|
+
status: 200
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
collectionPath: '/postcodes',
|
|
203
|
+
collectionLoader: async ({
|
|
204
|
+
q
|
|
205
|
+
}) => {
|
|
206
|
+
const result = await (0, _addressService.searchForPostcode)(q || '');
|
|
207
|
+
const buckets = result.body.aggregations.postcodes.buckets;
|
|
208
|
+
const body = buckets.map(bucket => ({
|
|
209
|
+
postcode: bucket.key,
|
|
210
|
+
localities: bucket.localities.buckets.map(l => ({
|
|
211
|
+
name: l.key
|
|
212
|
+
}))
|
|
213
|
+
}));
|
|
214
|
+
const responseHash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
|
|
215
|
+
return {
|
|
216
|
+
body,
|
|
217
|
+
hasMore: false,
|
|
218
|
+
headers: {
|
|
219
|
+
etag: `"${_version.version}-${responseHash}"`,
|
|
220
|
+
'cache-control': `public, max-age=${ONE_WEEK}`
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
filters: [{
|
|
225
|
+
rel: 'https://addressr.io/rels/postcode-search',
|
|
226
|
+
parameters: ['q']
|
|
227
|
+
}]
|
|
228
|
+
});
|
|
229
|
+
const statesType = waycharter.registerCollection({
|
|
230
|
+
itemPath: '/:abbreviation',
|
|
231
|
+
itemLoader: async ({
|
|
232
|
+
abbreviation
|
|
233
|
+
}) => {
|
|
234
|
+
const result = await (0, _addressService.getState)(abbreviation);
|
|
235
|
+
const stateName = result.body.aggregations.state_name.buckets[0]?.key || abbreviation.toUpperCase();
|
|
236
|
+
const localities = result.body.aggregations.localities.buckets.map(l => ({
|
|
237
|
+
name: l.key
|
|
238
|
+
}));
|
|
239
|
+
const postcodes = result.body.aggregations.postcodes.buckets.map(p => p.key);
|
|
240
|
+
const body = {
|
|
241
|
+
abbreviation: abbreviation.toUpperCase(),
|
|
242
|
+
name: stateName,
|
|
243
|
+
localities,
|
|
244
|
+
postcodes
|
|
245
|
+
};
|
|
246
|
+
const hash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
|
|
247
|
+
return {
|
|
248
|
+
body,
|
|
249
|
+
headers: {
|
|
250
|
+
etag: `"${_version.version}-${hash}"`,
|
|
251
|
+
'cache-control': `public, max-age=${ONE_WEEK}`
|
|
252
|
+
},
|
|
253
|
+
status: 200
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
collectionPath: '/states',
|
|
257
|
+
collectionLoader: async ({
|
|
258
|
+
q
|
|
259
|
+
}) => {
|
|
260
|
+
const result = await (0, _addressService.searchForState)(q || undefined);
|
|
261
|
+
const buckets = result.body.aggregations.states.buckets;
|
|
262
|
+
const body = buckets.map(bucket => ({
|
|
263
|
+
abbreviation: bucket.key,
|
|
264
|
+
name: bucket.state_name.buckets[0]?.key || bucket.key
|
|
265
|
+
}));
|
|
266
|
+
const responseHash = _nodeCrypto.default.createHash('md5').update(JSON.stringify(body)).digest('hex');
|
|
267
|
+
return {
|
|
268
|
+
body,
|
|
269
|
+
hasMore: false,
|
|
270
|
+
headers: {
|
|
271
|
+
etag: `"${_version.version}-${responseHash}"`,
|
|
272
|
+
'cache-control': `public, max-age=${ONE_WEEK}`
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
filters: [{
|
|
277
|
+
rel: 'https://addressr.io/rels/state-search',
|
|
278
|
+
parameters: ['q']
|
|
279
|
+
}]
|
|
280
|
+
});
|
|
110
281
|
waycharter.registerResourceType({
|
|
111
282
|
path: '/health',
|
|
112
283
|
loader: async () => {
|
|
@@ -127,7 +298,7 @@ function startRest2Server() {
|
|
|
127
298
|
loader: async () => {
|
|
128
299
|
return {
|
|
129
300
|
body: {},
|
|
130
|
-
links: [...addressesType.additionalPaths, {
|
|
301
|
+
links: [...addressesType.additionalPaths, ...localitiesType.additionalPaths, ...postcodesType.additionalPaths, ...statesType.additionalPaths, {
|
|
131
302
|
rel: 'https://addressr.io/rels/health',
|
|
132
303
|
uri: '/health'
|
|
133
304
|
}],
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
}
|