@aryacode/dsh-engineering 0.8.3 → 0.9.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.
- package/CHANGELOG.md +43 -0
- package/README.md +7 -1
- package/cordis.patch.yml +7 -0
- package/entries/resources.js +23 -0
- package/package.json +6 -3
- package/vendor/ftview/SKILL.md +4 -1
- package/vendor/ftview/lib/edit/tools.d.ts +1 -1
- package/vendor/ftview/lib/edit/tools.js +26 -6
- package/vendor/ftview/lib/edit.js +2 -1
- package/vendor/ftview/lib/session.d.ts +9 -0
- package/vendor/ftview/lib/session.js +11 -0
- package/vendor/ftview/lib/style.d.ts +60 -0
- package/vendor/ftview/lib/style.js +109 -0
- package/vendor/ftview/lib/vendor-tools.js +5 -2
- package/vendor/ftview/lib/vendor.d.ts +43 -3
- package/vendor/ftview/lib/vendor.js +95 -5
- package/vendor/l5x/lib/engine/style.d.ts +67 -0
- package/vendor/l5x/lib/engine/style.js +119 -0
- package/vendor/l5x/lib/session.d.ts +9 -0
- package/vendor/l5x/lib/session.js +11 -0
- package/vendor/resources/SKILL.md +27 -0
- package/vendor/resources/lib/corpus.d.ts +80 -0
- package/vendor/resources/lib/corpus.js +126 -0
- package/vendor/resources/lib/index.d.ts +55 -0
- package/vendor/resources/lib/index.js +75 -0
- package/vendor/resources/lib/profile.d.ts +113 -0
- package/vendor/resources/lib/profile.js +52 -0
- package/vendor/resources/lib/store.d.ts +22 -0
- package/vendor/resources/lib/store.js +35 -0
- package/vendor/resources/lib/tools.d.ts +138 -0
- package/vendor/resources/lib/tools.js +190 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module dsh-engineering-ftview/vendor
|
|
12
12
|
*/
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
/** CLSID of the ME export server (`MEExport.exe`). */
|
|
17
17
|
export const EXPORT_CLSID = '9d882577-aa92-466b-a749-6d6b5093b623';
|
|
@@ -88,13 +88,24 @@ export async function listApplications() {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
/**
|
|
91
|
-
* Export one application's displays and
|
|
91
|
+
* Export one application's displays, globals, alarms and tags into a new folder.
|
|
92
|
+
*
|
|
93
|
+
* The export server covers displays, global objects and project settings. The
|
|
94
|
+
* alarm setup and the tag database live beside the application in the Studio
|
|
95
|
+
* project store, and `ExportAll` does not copy them, so this function does:
|
|
96
|
+
* without them a "full" export has no tags to bind and no alarms to review.
|
|
97
|
+
*
|
|
98
|
+
* Those two files are snapshots Studio writes when it exports them from their
|
|
99
|
+
* editors, not live views of the database, so each copy is checked against the
|
|
100
|
+
* application database's timestamp and a stale one is reported rather than
|
|
101
|
+
* silently handed over as current.
|
|
92
102
|
*
|
|
93
103
|
* @param application - Application name as listed.
|
|
94
104
|
* @param destination - Folder to create and fill.
|
|
95
|
-
* @
|
|
105
|
+
* @param documentsRoot - Public Documents folder, injectable for tests.
|
|
106
|
+
* @returns Files written and any freshness warnings.
|
|
96
107
|
*/
|
|
97
|
-
export async function exportApplication(application, destination) {
|
|
108
|
+
export async function exportApplication(application, destination, documentsRoot) {
|
|
98
109
|
if (existsSync(destination))
|
|
99
110
|
return { error: `A file or folder already exists at ${destination}. Export into a new folder.` };
|
|
100
111
|
const result = await runPowerShell('powershell.exe', EXPORT_SCRIPT, { clsid: EXPORT_CLSID, app: application, dest: destination });
|
|
@@ -102,6 +113,7 @@ export async function exportApplication(application, destination) {
|
|
|
102
113
|
return { error: `Export failed: ${result.stderr.trim() || `exit ${result.code}`}` };
|
|
103
114
|
if (!existsSync(destination))
|
|
104
115
|
return { error: 'The engine reported success but wrote no folder.' };
|
|
116
|
+
const extras = copyProjectExtras(application, destination, documentsRoot);
|
|
105
117
|
const files = [];
|
|
106
118
|
const walk = (directory, prefix) => {
|
|
107
119
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
@@ -113,7 +125,85 @@ export async function exportApplication(application, destination) {
|
|
|
113
125
|
}
|
|
114
126
|
};
|
|
115
127
|
walk(destination, '');
|
|
116
|
-
return { value: files.sort() };
|
|
128
|
+
return { value: { files: files.sort(), warnings: extras.warnings } };
|
|
129
|
+
}
|
|
130
|
+
/** The Studio project store that holds one application's alarms and tags. */
|
|
131
|
+
export function projectFolder(application, documentsRoot = join(process.env.PUBLIC ?? 'C:/Users/Public', 'Documents')) {
|
|
132
|
+
const folder = join(documentsRoot, 'RSView Enterprise/ME/HMI projects', application);
|
|
133
|
+
return existsSync(folder) ? folder : undefined;
|
|
134
|
+
}
|
|
135
|
+
/** A readable date for one file's timestamp. */
|
|
136
|
+
function modifiedOn(path) {
|
|
137
|
+
try {
|
|
138
|
+
return statSync(path).mtime.toISOString().slice(0, 10);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return 'unknown';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** A warning when one copied snapshot predates the application database. */
|
|
145
|
+
function freshness(file, relative, database) {
|
|
146
|
+
if (database === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
try {
|
|
149
|
+
if (statSync(file).mtimeMs + 60_000 >= statSync(database).mtimeMs)
|
|
150
|
+
return undefined;
|
|
151
|
+
return `${relative} was written ${modifiedOn(file)}, older than the application database (${modifiedOn(database)}); it may not reflect recent changes. Studio refreshes it on its next full export.`;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Copy the alarm setup and the tag CSV from the project store into an export.
|
|
159
|
+
*
|
|
160
|
+
* The file names and layout match what Studio writes when it exports the whole
|
|
161
|
+
* application (`Alarms/Alarms.xml`, `Tags/<application>.csv`), so a reader
|
|
162
|
+
* cannot tell this export from a hand-made one.
|
|
163
|
+
*
|
|
164
|
+
* @param application - Application name.
|
|
165
|
+
* @param destination - Export folder that already exists.
|
|
166
|
+
* @param documentsRoot - Public Documents folder, injectable for tests.
|
|
167
|
+
* @returns Paths copied and any staleness warnings.
|
|
168
|
+
*/
|
|
169
|
+
export function copyProjectExtras(application, destination, documentsRoot) {
|
|
170
|
+
const source = projectFolder(application, documentsRoot);
|
|
171
|
+
if (source === undefined) {
|
|
172
|
+
return { copied: [], warnings: [`No Studio project folder was found for ${application}, so alarms and tags were not exported.`] };
|
|
173
|
+
}
|
|
174
|
+
const database = join(source, `${application}.med`);
|
|
175
|
+
const copied = [];
|
|
176
|
+
const warnings = [];
|
|
177
|
+
const alarms = join(source, 'Alarms.xml');
|
|
178
|
+
if (existsSync(alarms)) {
|
|
179
|
+
const target = join(destination, 'Alarms');
|
|
180
|
+
mkdirSync(target, { recursive: true });
|
|
181
|
+
copyFileSync(alarms, join(target, 'Alarms.xml'));
|
|
182
|
+
copied.push('Alarms/Alarms.xml');
|
|
183
|
+
const warning = freshness(alarms, 'Alarms/Alarms.xml', existsSync(database) ? database : undefined);
|
|
184
|
+
if (warning !== undefined)
|
|
185
|
+
warnings.push(warning);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
warnings.push('No Alarms.xml in the application store, so alarms were not exported. Export alarm setup from Studio once.');
|
|
189
|
+
}
|
|
190
|
+
// Studio names the tag file `<application>-Tags.CSV`; match the suffix
|
|
191
|
+
// case-insensitively because older applications used `.csv`.
|
|
192
|
+
const entries = readdirSync(source, { withFileTypes: true });
|
|
193
|
+
const tags = entries.find(entry => entry.isFile() && /-tags\.csv$/iu.test(entry.name));
|
|
194
|
+
if (tags !== undefined) {
|
|
195
|
+
const target = join(destination, 'Tags');
|
|
196
|
+
mkdirSync(target, { recursive: true });
|
|
197
|
+
copyFileSync(join(source, tags.name), join(target, `${application}.csv`));
|
|
198
|
+
copied.push(`Tags/${application}.csv`);
|
|
199
|
+
const warning = freshness(join(source, tags.name), `Tags/${application}.csv`, existsSync(database) ? database : undefined);
|
|
200
|
+
if (warning !== undefined)
|
|
201
|
+
warnings.push(warning);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
warnings.push('No tag CSV in the application store, so tags were not exported. Export the tag database from Studio once.');
|
|
205
|
+
}
|
|
206
|
+
return { copied, warnings };
|
|
117
207
|
}
|
|
118
208
|
/**
|
|
119
209
|
* Back one application up to a zip.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infer the house style of one L5X project.
|
|
3
|
+
*
|
|
4
|
+
* Every number here is counted from the export, and the shape is deliberately
|
|
5
|
+
* the bundle-wide profile schema (`packages/resources/src/profile.ts`) rather
|
|
6
|
+
* than an L5X-specific one, so the resources row can validate and store it.
|
|
7
|
+
* Nothing is inferred from fewer than a handful of samples, and the confidence
|
|
8
|
+
* field says how much of the project the inference actually saw.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-engineering-l5x/engine/style
|
|
11
|
+
*/
|
|
12
|
+
import type { ProjectSnapshot } from '../session.ts';
|
|
13
|
+
/** One counted value. */
|
|
14
|
+
interface Counted {
|
|
15
|
+
readonly value: string;
|
|
16
|
+
readonly count: number;
|
|
17
|
+
}
|
|
18
|
+
/** Evidence for one claim. */
|
|
19
|
+
interface Evidence {
|
|
20
|
+
readonly file?: string;
|
|
21
|
+
readonly line?: number;
|
|
22
|
+
readonly count?: number;
|
|
23
|
+
readonly note: string;
|
|
24
|
+
}
|
|
25
|
+
/** The profile, structurally compatible with the resources schema. */
|
|
26
|
+
export interface L5xStyleProfile {
|
|
27
|
+
readonly schema: 1;
|
|
28
|
+
readonly source: {
|
|
29
|
+
readonly kind: 'l5x';
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly sha256: string;
|
|
32
|
+
};
|
|
33
|
+
readonly naming: {
|
|
34
|
+
readonly tags: {
|
|
35
|
+
readonly prefixes: readonly Counted[];
|
|
36
|
+
readonly case: string;
|
|
37
|
+
};
|
|
38
|
+
readonly routines: {
|
|
39
|
+
readonly case: string;
|
|
40
|
+
readonly examples: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
readonly idioms: {
|
|
44
|
+
readonly sealIn: {
|
|
45
|
+
readonly count: number;
|
|
46
|
+
readonly form: string;
|
|
47
|
+
};
|
|
48
|
+
readonly latch: {
|
|
49
|
+
readonly count: number;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
readonly comments: {
|
|
53
|
+
readonly total: number;
|
|
54
|
+
readonly commented: number;
|
|
55
|
+
readonly ratio: number;
|
|
56
|
+
};
|
|
57
|
+
readonly evidence: readonly Evidence[];
|
|
58
|
+
readonly confidence: 'low' | 'medium' | 'high';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the style of one project.
|
|
62
|
+
*
|
|
63
|
+
* @param project - Snapshot the session opened.
|
|
64
|
+
* @returns The profile.
|
|
65
|
+
*/
|
|
66
|
+
export declare function styleProfileOf(project: ProjectSnapshot): L5xStyleProfile;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infer the house style of one L5X project.
|
|
3
|
+
*
|
|
4
|
+
* Every number here is counted from the export, and the shape is deliberately
|
|
5
|
+
* the bundle-wide profile schema (`packages/resources/src/profile.ts`) rather
|
|
6
|
+
* than an L5X-specific one, so the resources row can validate and store it.
|
|
7
|
+
* Nothing is inferred from fewer than a handful of samples, and the confidence
|
|
8
|
+
* field says how much of the project the inference actually saw.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-engineering-l5x/engine/style
|
|
11
|
+
*/
|
|
12
|
+
/** Classify one identifier's case convention. */
|
|
13
|
+
function caseOf(identifier) {
|
|
14
|
+
if (/^[A-Z][A-Za-z0-9]*$/u.test(identifier))
|
|
15
|
+
return 'PascalCase';
|
|
16
|
+
if (/^[a-z][A-Za-z0-9]*$/u.test(identifier))
|
|
17
|
+
return 'camelCase';
|
|
18
|
+
if (/^[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+$/u.test(identifier))
|
|
19
|
+
return 'snake_case';
|
|
20
|
+
return 'other';
|
|
21
|
+
}
|
|
22
|
+
/** The most common value in a counter, with its count. */
|
|
23
|
+
function top(counts) {
|
|
24
|
+
let best;
|
|
25
|
+
for (const [value, count] of counts) {
|
|
26
|
+
if (best === undefined || count > best.count)
|
|
27
|
+
best = { value, count };
|
|
28
|
+
}
|
|
29
|
+
return best;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the style of one project.
|
|
33
|
+
*
|
|
34
|
+
* @param project - Snapshot the session opened.
|
|
35
|
+
* @returns The profile.
|
|
36
|
+
*/
|
|
37
|
+
export function styleProfileOf(project) {
|
|
38
|
+
const document = project.document;
|
|
39
|
+
const rungs = document.routines
|
|
40
|
+
.filter(routine => routine.body.type === 'RLL')
|
|
41
|
+
.flatMap(routine => (routine.body.type === 'RLL' ? routine.body.rungs.map(rung => ({ routine: routine.name, rung })) : []));
|
|
42
|
+
const identifiers = new Map();
|
|
43
|
+
const prefixes = new Map();
|
|
44
|
+
const cases = new Map();
|
|
45
|
+
let sealIn = 0;
|
|
46
|
+
let latchInstructions = 0;
|
|
47
|
+
let commented = 0;
|
|
48
|
+
const evidence = [];
|
|
49
|
+
const aoiNames = new Set(document.aois.map(aoi => aoi.name.toUpperCase()));
|
|
50
|
+
let firstSealInRung;
|
|
51
|
+
for (const { routine, rung } of rungs) {
|
|
52
|
+
if (rung.comment !== undefined)
|
|
53
|
+
commented += 1;
|
|
54
|
+
const text = rung.text;
|
|
55
|
+
for (const match of text.matchAll(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*/gu)) {
|
|
56
|
+
const whole = match[0];
|
|
57
|
+
if (aoiNames.has(whole.toUpperCase()))
|
|
58
|
+
continue;
|
|
59
|
+
const base = whole.split('.')[0] ?? whole;
|
|
60
|
+
if (base.length < 5)
|
|
61
|
+
continue;
|
|
62
|
+
identifiers.set(base, (identifiers.get(base) ?? 0) + 1);
|
|
63
|
+
const parts = base.split('_');
|
|
64
|
+
if (parts.length > 1) {
|
|
65
|
+
const prefix = parts[0];
|
|
66
|
+
if (/^[A-Z][A-Za-z]{1,6}$/u.test(prefix) && base.length > prefix.length + 3) {
|
|
67
|
+
prefixes.set(prefix, (prefixes.get(prefix) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const kind = caseOf(base);
|
|
71
|
+
if (kind !== 'other')
|
|
72
|
+
cases.set(kind, (cases.get(kind) ?? 0) + 1);
|
|
73
|
+
}
|
|
74
|
+
for (const coil of text.matchAll(/OTE\(([^),]+)\)/gu)) {
|
|
75
|
+
const target = (coil[1] ?? '').trim();
|
|
76
|
+
const contact = `XIC(${target})`;
|
|
77
|
+
const coilIndex = coil.index ?? 0;
|
|
78
|
+
const contactIndex = text.indexOf(contact);
|
|
79
|
+
if (contactIndex >= 0 && contactIndex < coilIndex) {
|
|
80
|
+
sealIn += 1;
|
|
81
|
+
if (firstSealInRung === undefined)
|
|
82
|
+
firstSealInRung = rung.number;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
latchInstructions += (text.match(/OT[LU]\(/gu) ?? []).length;
|
|
86
|
+
}
|
|
87
|
+
const total = rungs.length;
|
|
88
|
+
const dominant = top(prefixes);
|
|
89
|
+
const dominantCase = top(cases);
|
|
90
|
+
const prefixShare = dominant === undefined ? 0 : dominant.count / Math.max(1, [...prefixes.values()].reduce((sum, count) => sum + count, 0));
|
|
91
|
+
const routineCases = new Map();
|
|
92
|
+
for (const routine of document.routines) {
|
|
93
|
+
const kind = caseOf(routine.name);
|
|
94
|
+
if (kind !== 'other')
|
|
95
|
+
routineCases.set(kind, (routineCases.get(kind) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
const routineCase = top(routineCases);
|
|
98
|
+
if (dominant !== undefined)
|
|
99
|
+
evidence.push({ count: dominant.count, note: `tag prefix "${dominant.value}" appears ${dominant.count} time(s)` });
|
|
100
|
+
if (firstSealInRung !== undefined)
|
|
101
|
+
evidence.push({ line: firstSealInRung, count: sealIn, note: `seal-in rungs detected from rung ${firstSealInRung}` });
|
|
102
|
+
evidence.push({ count: total, note: `${total} ladder rung(s) read, ${commented} with a comment` });
|
|
103
|
+
const confidence = total >= 50 && prefixShare >= 0.5 ? 'high' : total >= 10 ? 'medium' : 'low';
|
|
104
|
+
return {
|
|
105
|
+
schema: 1,
|
|
106
|
+
source: { kind: 'l5x', path: project.path, sha256: project.sha256 },
|
|
107
|
+
naming: {
|
|
108
|
+
tags: { prefixes: [...prefixes].sort((left, right) => right[1] - left[1]).slice(0, 6).map(([value, count]) => ({ value, count })), case: dominantCase?.value ?? 'PascalCase' },
|
|
109
|
+
routines: { case: routineCase?.value ?? 'PascalCase', examples: document.routines.slice(0, 5).map(routine => routine.name) },
|
|
110
|
+
},
|
|
111
|
+
idioms: {
|
|
112
|
+
sealIn: { count: sealIn, form: sealIn > 0 ? 'XIC(seal) in parallel with the start contact, in series with the stop contact' : 'not observed' },
|
|
113
|
+
latch: { count: latchInstructions },
|
|
114
|
+
},
|
|
115
|
+
comments: { total, commented, ratio: total === 0 ? 0 : Number((commented / total).toFixed(2)) },
|
|
116
|
+
evidence,
|
|
117
|
+
confidence,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* @module dsh-engineering-l5x/session
|
|
13
13
|
*/
|
|
14
14
|
import { type L5xDocument } from './model/index.ts';
|
|
15
|
+
import { type L5xStyleProfile } from './engine/style.ts';
|
|
15
16
|
import type { ExecContext } from './core/tool.ts';
|
|
16
17
|
import { L5xError } from './core/errors.ts';
|
|
17
18
|
import type { FsService, FsTarget } from './core/tool.ts';
|
|
@@ -93,6 +94,14 @@ export declare class ProjectSession {
|
|
|
93
94
|
* @returns The open project.
|
|
94
95
|
*/
|
|
95
96
|
require(projectId: string | undefined, exec?: ExecContext): Promise<ProjectSnapshot>;
|
|
97
|
+
/**
|
|
98
|
+
* Read the house style of one open project.
|
|
99
|
+
*
|
|
100
|
+
* @param projectId - Explicit handle, or undefined for the chat's only project.
|
|
101
|
+
* @param exec - Calling context.
|
|
102
|
+
* @returns The profile in the shared resources schema.
|
|
103
|
+
*/
|
|
104
|
+
styleProfile(projectId: string | undefined, exec?: ExecContext): Promise<L5xStyleProfile>;
|
|
96
105
|
/**
|
|
97
106
|
* Resolve one path through the filesystem provider without reading it.
|
|
98
107
|
*
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
15
|
import { L5xParseError, loadL5x } from "./model/index.js";
|
|
16
|
+
import { styleProfileOf } from "./engine/style.js";
|
|
16
17
|
import { L5xError } from "./core/errors.js";
|
|
17
18
|
import { decodeExport } from "./fs.js";
|
|
18
19
|
/** A failure raised by an analysis engine. */
|
|
@@ -130,6 +131,16 @@ export class ProjectSession {
|
|
|
130
131
|
}
|
|
131
132
|
return project;
|
|
132
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Read the house style of one open project.
|
|
136
|
+
*
|
|
137
|
+
* @param projectId - Explicit handle, or undefined for the chat's only project.
|
|
138
|
+
* @param exec - Calling context.
|
|
139
|
+
* @returns The profile in the shared resources schema.
|
|
140
|
+
*/
|
|
141
|
+
async styleProfile(projectId, exec) {
|
|
142
|
+
return styleProfileOf(await this.require(projectId, exec));
|
|
143
|
+
}
|
|
133
144
|
/**
|
|
134
145
|
* Resolve one path through the filesystem provider without reading it.
|
|
135
146
|
*
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Engineering standards
|
|
2
|
+
|
|
3
|
+
Use this skill whenever you write, change or review control code or HMI screens — before you author, not
|
|
4
|
+
after.
|
|
5
|
+
|
|
6
|
+
## Read first
|
|
7
|
+
|
|
8
|
+
- `engineering_reference` with no arguments lists the corpus. Search it for the language and the domain
|
|
9
|
+
you are about to touch (`iec state machine`, `rockwell naming`, `view alarm`, `craft fault`), then read
|
|
10
|
+
the page. Each page carries rule IDs; cite the ID when you report a finding.
|
|
11
|
+
- When a project or export is open, call `engineering_profile` **before** writing. It reports the naming,
|
|
12
|
+
idioms and visual conventions the plant already uses, with evidence and a confidence level. Follow the
|
|
13
|
+
profile where it is confident; where it is low, say so and ask.
|
|
14
|
+
|
|
15
|
+
## Order of work
|
|
16
|
+
|
|
17
|
+
1. Read the profile (existing project) or the standard pages (new work).
|
|
18
|
+
2. Author the change with the project's own names and visual language.
|
|
19
|
+
3. `engineering_review` on the draft. Fix findings you agree with; explain the ones you do not.
|
|
20
|
+
4. Then run the product's own validation (`draft_validate`, `ftview_validate`, `mw_compile`).
|
|
21
|
+
|
|
22
|
+
## Honesty rules
|
|
23
|
+
|
|
24
|
+
- A profile is evidence, not authority: quote the counts and the source path when you rely on it.
|
|
25
|
+
- A rule ID is this bundle's opinion. Never present it as a standards clause or a certification.
|
|
26
|
+
- Do not reproduce standards text; link the page instead.
|
|
27
|
+
- If the corpus or a profile is unavailable, say which one and continue with the product's own checks.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and searching the engineering corpus.
|
|
3
|
+
*
|
|
4
|
+
* The corpus is Markdown with front matter; `scripts/gen-resources.mjs` turns it
|
|
5
|
+
* into `index.json` and `SOURCE.json`. This module reads only those generated
|
|
6
|
+
* files plus the page bodies, so searching is deterministic and needs no model,
|
|
7
|
+
* network or tokenizer.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-engineering-resources/corpus
|
|
10
|
+
*/
|
|
11
|
+
/** One page as the index records it. */
|
|
12
|
+
export interface PageRow {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly domain: string;
|
|
16
|
+
readonly level: string;
|
|
17
|
+
readonly basis: readonly string[];
|
|
18
|
+
readonly tags: readonly string[];
|
|
19
|
+
readonly applies_to: readonly string[];
|
|
20
|
+
readonly rules: readonly string[];
|
|
21
|
+
readonly words: number;
|
|
22
|
+
readonly bytes: number;
|
|
23
|
+
readonly sha256: string;
|
|
24
|
+
}
|
|
25
|
+
/** The generated index. */
|
|
26
|
+
export interface CorpusIndex {
|
|
27
|
+
readonly corpus: number;
|
|
28
|
+
readonly pages: readonly PageRow[];
|
|
29
|
+
}
|
|
30
|
+
/** One hit from a search. */
|
|
31
|
+
export interface CorpusHit {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly title: string;
|
|
34
|
+
readonly domain: string;
|
|
35
|
+
readonly level: string;
|
|
36
|
+
readonly rules: readonly string[];
|
|
37
|
+
readonly score: number;
|
|
38
|
+
}
|
|
39
|
+
/** The corpus in memory, or the reason it is unavailable. */
|
|
40
|
+
export interface Corpus {
|
|
41
|
+
readonly index: CorpusIndex;
|
|
42
|
+
readonly digest: string;
|
|
43
|
+
readonly directory: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Load the index and source pin once per process.
|
|
47
|
+
*
|
|
48
|
+
* @returns The corpus.
|
|
49
|
+
* @throws when the generated files are missing, which means the build did not run.
|
|
50
|
+
*/
|
|
51
|
+
export declare function corpus(): Corpus;
|
|
52
|
+
/**
|
|
53
|
+
* Read one page body.
|
|
54
|
+
*
|
|
55
|
+
* @param id - Page id, as listed by the contents.
|
|
56
|
+
* @returns Markdown body.
|
|
57
|
+
*/
|
|
58
|
+
export declare function pageBody(id: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Rank pages by metadata, then by body text for the remainder.
|
|
61
|
+
*
|
|
62
|
+
* @param query - Free text, rule id, tag or page id.
|
|
63
|
+
* @param limit - Most hits to return.
|
|
64
|
+
* @param domain - Optional domain filter.
|
|
65
|
+
* @returns Ranked hits, best first.
|
|
66
|
+
*/
|
|
67
|
+
export declare function search(query: string, limit?: number, domain?: string): readonly CorpusHit[];
|
|
68
|
+
/** Domain order for the contents listing. */
|
|
69
|
+
export declare const DOMAINS: readonly ["iec", "rockwell", "yaskawa", "view", "optix", "craft"];
|
|
70
|
+
/** The contents, grouped by domain, with word counts. */
|
|
71
|
+
export declare function contents(): {
|
|
72
|
+
domains: readonly string[];
|
|
73
|
+
pages: readonly {
|
|
74
|
+
id: string;
|
|
75
|
+
title: string;
|
|
76
|
+
domain: string;
|
|
77
|
+
level: string;
|
|
78
|
+
words: number;
|
|
79
|
+
}[];
|
|
80
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and searching the engineering corpus.
|
|
3
|
+
*
|
|
4
|
+
* The corpus is Markdown with front matter; `scripts/gen-resources.mjs` turns it
|
|
5
|
+
* into `index.json` and `SOURCE.json`. This module reads only those generated
|
|
6
|
+
* files plus the page bodies, so searching is deterministic and needs no model,
|
|
7
|
+
* network or tokenizer.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-engineering-resources/corpus
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
/** Where the corpus lives, relative to the built module. */
|
|
15
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
let cached;
|
|
17
|
+
/**
|
|
18
|
+
* Load the index and source pin once per process.
|
|
19
|
+
*
|
|
20
|
+
* @returns The corpus.
|
|
21
|
+
* @throws when the generated files are missing, which means the build did not run.
|
|
22
|
+
*/
|
|
23
|
+
export function corpus() {
|
|
24
|
+
if (cached !== undefined)
|
|
25
|
+
return cached;
|
|
26
|
+
const indexPath = join(ROOT, 'index.json');
|
|
27
|
+
const sourcePath = join(ROOT, 'SOURCE.json');
|
|
28
|
+
if (!existsSync(indexPath) || !existsSync(sourcePath)) {
|
|
29
|
+
throw Object.assign(new Error('The engineering corpus is missing; run `pnpm resources:build` before packing.'), { code: 'RESOURCES_MISSING' });
|
|
30
|
+
}
|
|
31
|
+
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
32
|
+
const source = JSON.parse(readFileSync(sourcePath, 'utf8'));
|
|
33
|
+
if (!Array.isArray(index.pages) || index.pages.length === 0) {
|
|
34
|
+
throw Object.assign(new Error('The engineering corpus is empty; run `pnpm resources:build`.'), { code: 'RESOURCES_MISSING' });
|
|
35
|
+
}
|
|
36
|
+
cached = { index, digest: source.digest, directory: ROOT };
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read one page body.
|
|
41
|
+
*
|
|
42
|
+
* @param id - Page id, as listed by the contents.
|
|
43
|
+
* @returns Markdown body.
|
|
44
|
+
*/
|
|
45
|
+
export function pageBody(id) {
|
|
46
|
+
const { index } = corpus();
|
|
47
|
+
const row = index.pages.find(page => page.id === id);
|
|
48
|
+
if (row === undefined) {
|
|
49
|
+
throw Object.assign(new Error(`No resource page named "${id}".`), { code: 'COMPONENT_NOT_FOUND' });
|
|
50
|
+
}
|
|
51
|
+
const path = join(ROOT, 'corpus', row.domain, `${row.id}.md`);
|
|
52
|
+
if (!existsSync(path)) {
|
|
53
|
+
throw Object.assign(new Error(`The page file for "${id}" is missing from the corpus.`), { code: 'RESOURCES_MISSING' });
|
|
54
|
+
}
|
|
55
|
+
// Strip the front matter: the index already carries it, and repeating it in
|
|
56
|
+
// the answer spends tokens for nothing.
|
|
57
|
+
return readFileSync(path, 'utf8').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, '');
|
|
58
|
+
}
|
|
59
|
+
/** Score one page against a query. Higher is better; zero is no hit. */
|
|
60
|
+
function scoreOf(row, needle) {
|
|
61
|
+
if (row.id === needle)
|
|
62
|
+
return 100;
|
|
63
|
+
if (row.rules.some(rule => rule.toLowerCase() === needle))
|
|
64
|
+
return 95;
|
|
65
|
+
if (row.title.toLowerCase() === needle)
|
|
66
|
+
return 90;
|
|
67
|
+
if (row.tags.some(tag => tag.toLowerCase() === needle))
|
|
68
|
+
return 80;
|
|
69
|
+
if (row.id.includes(needle))
|
|
70
|
+
return 60;
|
|
71
|
+
if (row.title.toLowerCase().includes(needle))
|
|
72
|
+
return 55;
|
|
73
|
+
if (row.tags.some(tag => tag.toLowerCase().includes(needle)))
|
|
74
|
+
return 45;
|
|
75
|
+
if (row.rules.some(rule => rule.toLowerCase().startsWith(needle)))
|
|
76
|
+
return 40;
|
|
77
|
+
if (row.domain === needle)
|
|
78
|
+
return 35;
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Rank pages by metadata, then by body text for the remainder.
|
|
83
|
+
*
|
|
84
|
+
* @param query - Free text, rule id, tag or page id.
|
|
85
|
+
* @param limit - Most hits to return.
|
|
86
|
+
* @param domain - Optional domain filter.
|
|
87
|
+
* @returns Ranked hits, best first.
|
|
88
|
+
*/
|
|
89
|
+
export function search(query, limit = 8, domain) {
|
|
90
|
+
const { index } = corpus();
|
|
91
|
+
const needle = query.trim().toLowerCase();
|
|
92
|
+
const tokens = needle.split(/\s+/u).filter(Boolean);
|
|
93
|
+
const pool = domain === undefined ? index.pages : index.pages.filter(page => page.domain === domain);
|
|
94
|
+
const scored = pool
|
|
95
|
+
.map(row => ({ row, score: tokens.reduce((sum, token) => sum + scoreOf(row, token), 0) }))
|
|
96
|
+
.filter(hit => hit.score > 0);
|
|
97
|
+
if (scored.length < limit) {
|
|
98
|
+
const seen = new Set(scored.map(hit => hit.row.id));
|
|
99
|
+
for (const row of pool) {
|
|
100
|
+
if (seen.has(row.id))
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
const body = pageBody(row.id).toLowerCase();
|
|
104
|
+
if (tokens.every(token => body.includes(token)))
|
|
105
|
+
scored.push({ row, score: 20 });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// A missing page file is reported when it is read, not during ranking.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return scored
|
|
113
|
+
.sort((left, right) => right.score - left.score || left.row.id.localeCompare(right.row.id))
|
|
114
|
+
.slice(0, Math.min(Math.max(limit, 1), 20))
|
|
115
|
+
.map(hit => ({ id: hit.row.id, title: hit.row.title, domain: hit.row.domain, level: hit.row.level, rules: hit.row.rules, score: hit.score }));
|
|
116
|
+
}
|
|
117
|
+
/** Domain order for the contents listing. */
|
|
118
|
+
export const DOMAINS = ['iec', 'rockwell', 'yaskawa', 'view', 'optix', 'craft'];
|
|
119
|
+
/** The contents, grouped by domain, with word counts. */
|
|
120
|
+
export function contents() {
|
|
121
|
+
const { index } = corpus();
|
|
122
|
+
return {
|
|
123
|
+
domains: DOMAINS.filter(domain => index.pages.some(page => page.domain === domain)),
|
|
124
|
+
pages: index.pages.map(page => ({ id: page.id, title: page.title, domain: page.domain, level: page.level, words: page.words })),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engineering resources row: a local standards corpus, a house-style profile
|
|
3
|
+
* and the `engineering-standards` skill that tells a model to read both before
|
|
4
|
+
* authoring.
|
|
5
|
+
*
|
|
6
|
+
* The corpus is generated data (`index.json`, `SOURCE.json`) over authored
|
|
7
|
+
* Markdown; the profile is produced by whichever product package has a project
|
|
8
|
+
* open. This entry only wires them to the tool registry and, when the host offers
|
|
9
|
+
* it, to the skills service.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-engineering-resources
|
|
12
|
+
*/
|
|
13
|
+
import type { StyleProfile } from './profile.ts';
|
|
14
|
+
/** Plugin name in a composition. */
|
|
15
|
+
export declare const name = "engineering-resources";
|
|
16
|
+
/** The tools need the registry; the skill is registered when the host offers it. */
|
|
17
|
+
export declare const inject: string[];
|
|
18
|
+
/** The service name a generation tool can inject to read a profile by handle. */
|
|
19
|
+
export declare const PROFILES_SERVICE = "engineeringProfiles";
|
|
20
|
+
/** The skill metadata, kept here so a test can assert it without the file. */
|
|
21
|
+
export declare const SKILL: {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
whenToUse: string;
|
|
25
|
+
};
|
|
26
|
+
/** Configuration for the row. */
|
|
27
|
+
export interface Config {
|
|
28
|
+
/** Whether the reference and profile tools are registered. Defaults to true. */
|
|
29
|
+
reference?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** The service exposed to other rows. */
|
|
32
|
+
export interface ProfilesService {
|
|
33
|
+
/** The profile for one handle, when it is still in memory. */
|
|
34
|
+
get(id: string): StyleProfile | undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Register the resource tools and the standards skill.
|
|
38
|
+
*
|
|
39
|
+
* @param ctx - Plugin context with the tool registry and optional services.
|
|
40
|
+
* @param config - Row configuration.
|
|
41
|
+
*/
|
|
42
|
+
export declare function apply(ctx: {
|
|
43
|
+
tools: {
|
|
44
|
+
register(definition: unknown): void;
|
|
45
|
+
};
|
|
46
|
+
get?: (name: string) => unknown;
|
|
47
|
+
provide?: (name: string, value: unknown) => void;
|
|
48
|
+
logger?: {
|
|
49
|
+
warn?(message: string): void;
|
|
50
|
+
};
|
|
51
|
+
}, config?: Config): void;
|
|
52
|
+
export { getProfile } from './store.ts';
|
|
53
|
+
export { validateProfile, profileId } from './profile.ts';
|
|
54
|
+
export type { StyleProfile } from './profile.ts';
|
|
55
|
+
export { resourceTools, referenceTool, profileTool } from './tools.ts';
|