dev-diagnosis-cli 0.0.0-stage → 0.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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Run one command without a shell.
3
+ *
4
+ * The Windows trap this avoids: building a shell string and quoting it by
5
+ * hand. On POSIX the command is run directly with argv, never through a
6
+ * shell, so no quoting is needed at all. On Windows a bare executable name
7
+ * such as `npm` is a `.cmd` shim, which spawnSync cannot run without a
8
+ * shell; only in that case — and only after the direct attempt fails with
9
+ * ENOENT — do we retry through cmd.exe, with each argument double-quoted.
10
+ * That retry path is the one part of this file not exercised on Linux; it
11
+ * is marked as such in the README.
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ export function runCommand(argv, options = {}) {
16
+ if (!Array.isArray(argv) || argv.length === 0 || typeof argv[0] !== 'string' || argv[0] === '') {
17
+ return { ok: false, status: null, stdout: '', stderr: '', ms: 0, error: 'no command was given' };
18
+ }
19
+ const cwd = options.cwd || process.cwd();
20
+ const timeoutMs = options.timeoutMs == null ? 120000 : options.timeoutMs;
21
+ const start = process.hrtime.bigint();
22
+ const ms = () => Math.round(Number(process.hrtime.bigint() - start) / 1e6 * 1000) / 1000;
23
+
24
+ let result;
25
+ try {
26
+ result = spawnSync(argv[0], argv.slice(1), {
27
+ cwd,
28
+ encoding: 'utf8',
29
+ timeout: timeoutMs,
30
+ windowsHide: true,
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ shell: false,
33
+ });
34
+ } catch (error) {
35
+ return { ok: false, status: null, stdout: '', stderr: '', ms: ms(), error: error.message };
36
+ }
37
+
38
+ if (result.error && result.error.code === 'ENOENT' && process.platform === 'win32') {
39
+ // Windows-only retry for .cmd/.bat shims (npm, yarn, ...).
40
+ const quoted = argv.map((arg) => `"${String(arg).replace(/"/g, '""')}"`).join(' ');
41
+ try {
42
+ result = spawnSync(quoted, {
43
+ cwd,
44
+ encoding: 'utf8',
45
+ timeout: timeoutMs,
46
+ windowsHide: true,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ shell: true,
49
+ });
50
+ } catch (error) {
51
+ return { ok: false, status: null, stdout: '', stderr: '', ms: ms(), error: error.message };
52
+ }
53
+ }
54
+
55
+ if (result.error) {
56
+ return {
57
+ ok: false,
58
+ status: typeof result.status === 'number' ? result.status : null,
59
+ stdout: result.stdout || '',
60
+ stderr: result.stderr || '',
61
+ ms: ms(),
62
+ error: result.error.message,
63
+ };
64
+ }
65
+ return {
66
+ ok: result.status === 0,
67
+ status: result.status,
68
+ stdout: result.stdout || '',
69
+ stderr: result.stderr || '',
70
+ ms: ms(),
71
+ error: null,
72
+ };
73
+ }
package/lib/xml.mjs ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * A deliberately small, dependency-free XML reader.
3
+ *
4
+ * Adapted from flake-ledger's lib/xml.mjs (WJDdidi-1/flake-ledger,
5
+ * Apache-2.0). Two changes for this project, both Windows-related:
6
+ * 1. parseXml normalises CRLF to LF before parsing, so a JUnit file
7
+ * written on Windows parses exactly like one written on POSIX.
8
+ * 2. Position reporting counts lines on the normalised text.
9
+ * It accepts the useful subset (elements, attributes, text, CDATA,
10
+ * comments, declarations, character entities) and rejects structurally
11
+ * broken documents with a line/column error.
12
+ */
13
+
14
+ const NAMED_ENTITIES = Object.freeze({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" });
15
+
16
+ export class XmlParseError extends Error {
17
+ constructor(message, source, index, sourceName = '<xml>') {
18
+ const { line, column } = positionAt(source, index);
19
+ super(`${sourceName}:${line}:${column}: ${message}`);
20
+ this.name = 'XmlParseError';
21
+ this.line = line;
22
+ this.column = column;
23
+ this.sourceName = sourceName;
24
+ }
25
+ }
26
+
27
+ function positionAt(source, index) {
28
+ const safeIndex = Math.max(0, Math.min(index, source.length));
29
+ let line = 1;
30
+ let lineStart = 0;
31
+ for (let i = 0; i < safeIndex; i += 1) {
32
+ if (source.charCodeAt(i) === 10) { line += 1; lineStart = i + 1; }
33
+ }
34
+ return { line, column: safeIndex - lineStart + 1 };
35
+ }
36
+
37
+ export function localName(name) {
38
+ if (typeof name !== 'string') return '';
39
+ const colon = name.lastIndexOf(':');
40
+ return colon === -1 ? name : name.slice(colon + 1);
41
+ }
42
+
43
+ export function isElement(node) {
44
+ return Boolean(node) && node.type === 'element';
45
+ }
46
+
47
+ export function elementChildren(node, name = null) {
48
+ if (!node || !Array.isArray(node.children)) return [];
49
+ const wanted = name === null ? null : String(name).toLowerCase();
50
+ return node.children.filter(
51
+ (child) => isElement(child) && (wanted === null || localName(child.name).toLowerCase() === wanted),
52
+ );
53
+ }
54
+
55
+ export function getAttribute(node, name) {
56
+ if (!node || !node.attrs) return undefined;
57
+ if (Object.prototype.hasOwnProperty.call(node.attrs, name)) return node.attrs[name];
58
+ const wanted = String(name).toLowerCase();
59
+ for (const [key, value] of Object.entries(node.attrs)) {
60
+ if (key === name) return value;
61
+ if (localName(key).toLowerCase() === wanted) return value;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ export function textContent(node) {
67
+ if (!node) return '';
68
+ if (node.type === 'text' || node.type === 'cdata') return node.value;
69
+ if (!Array.isArray(node.children)) return '';
70
+ let out = '';
71
+ for (const child of node.children) out += textContent(child);
72
+ return out;
73
+ }
74
+
75
+ export function decodeEntities(value) {
76
+ return String(value).replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9._-]*);/g, (match, body) => {
77
+ if (body[0] === '#') {
78
+ const hex = body[1] === 'x' || body[1] === 'X';
79
+ const codePoint = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
80
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;
81
+ try { return String.fromCodePoint(codePoint); } catch { return match; }
82
+ }
83
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : match;
84
+ });
85
+ }
86
+
87
+ export function parseXml(source, options = {}) {
88
+ if (typeof source !== 'string') throw new TypeError('parseXml expects a string');
89
+ // CRLF first: every position, line split and text comparison below then
90
+ // behaves identically on Windows- and POSIX-written files.
91
+ const text = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
92
+ const sourceName = options.sourceName || '<xml>';
93
+ const document = { type: 'document', name: '#document', attrs: Object.create(null), children: [] };
94
+ const stack = [document];
95
+ const length = text.length;
96
+ let index = 0;
97
+ const current = () => stack[stack.length - 1];
98
+ const fail = (message, at = index) => { throw new XmlParseError(message, text, at, sourceName); };
99
+ const appendText = (raw, cdata = false) => {
100
+ if (stack.length === 1) return;
101
+ const value = cdata ? raw : decodeEntities(raw);
102
+ if (value === '') return;
103
+ if (!cdata && value.trim() === '') return;
104
+ current().children.push({ type: cdata ? 'cdata' : 'text', value });
105
+ };
106
+
107
+ while (index < length) {
108
+ const open = text.indexOf('<', index);
109
+ if (open === -1) { appendText(text.slice(index)); index = length; break; }
110
+ if (open > index) appendText(text.slice(index, open));
111
+ index = open;
112
+ if (text.startsWith('<!--', index)) {
113
+ const end = text.indexOf('-->', index + 4);
114
+ if (end === -1) fail('unterminated comment');
115
+ index = end + 3;
116
+ continue;
117
+ }
118
+ if (text.startsWith('<![CDATA[', index)) {
119
+ const end = text.indexOf(']]>', index + 9);
120
+ if (end === -1) fail('unterminated CDATA section');
121
+ appendText(text.slice(index + 9, end), true);
122
+ index = end + 3;
123
+ continue;
124
+ }
125
+ if (text.startsWith('<?', index)) {
126
+ const end = text.indexOf('?>', index + 2);
127
+ if (end === -1) fail('unterminated processing instruction');
128
+ index = end + 2;
129
+ continue;
130
+ }
131
+ if (text.startsWith('<!', index)) {
132
+ let cursor = index + 2;
133
+ let quote = null;
134
+ let bracketDepth = 0;
135
+ let done = false;
136
+ while (cursor < length) {
137
+ const char = text[cursor];
138
+ if (quote) { if (char === quote) quote = null; }
139
+ else if (char === '"' || char === "'") quote = char;
140
+ else if (char === '[') bracketDepth += 1;
141
+ else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
142
+ else if (char === '>' && bracketDepth === 0) { done = true; cursor += 1; break; }
143
+ cursor += 1;
144
+ }
145
+ if (!done) fail('unterminated declaration');
146
+ index = cursor;
147
+ continue;
148
+ }
149
+ if (text.startsWith('</', index)) {
150
+ const end = text.indexOf('>', index + 2);
151
+ if (end === -1) fail('unterminated closing tag');
152
+ const rawName = text.slice(index + 2, end).trim();
153
+ if (rawName === '') fail('empty closing tag', index);
154
+ const name = rawName.split(/\s+/, 1)[0];
155
+ if (stack.length === 1) fail(`unexpected closing tag </${name}>`, index);
156
+ if (current().name !== name) fail(`closing tag </${name}> does not match <${current().name}>`, index);
157
+ stack.pop();
158
+ index = end + 1;
159
+ continue;
160
+ }
161
+ const start = parseStartTag(text, index, sourceName);
162
+ const node = { type: 'element', name: start.name, attrs: start.attrs, children: [] };
163
+ current().children.push(node);
164
+ if (!start.selfClosing) stack.push(node);
165
+ index = start.end;
166
+ }
167
+ if (stack.length > 1) fail(`unclosed tag <${stack[stack.length - 1].name}>`, length);
168
+ return document;
169
+ }
170
+
171
+ function parseStartTag(source, index, sourceName) {
172
+ const fail = (message, at = index) => { throw new XmlParseError(message, source, at, sourceName); };
173
+ let cursor = index + 1;
174
+ const length = source.length;
175
+ const nameStart = cursor;
176
+ while (cursor < length && !/[\s/>]/.test(source[cursor])) cursor += 1;
177
+ const name = source.slice(nameStart, cursor);
178
+ if (!name) fail('empty tag name');
179
+ if (name.startsWith('!') || name.startsWith('?')) fail(`unsupported markup <${name}>`);
180
+ const attrs = Object.create(null);
181
+ let selfClosing = false;
182
+ while (cursor < length) {
183
+ while (cursor < length && /\s/.test(source[cursor])) cursor += 1;
184
+ if (cursor >= length) fail(`unterminated tag <${name}>`);
185
+ if (source[cursor] === '>') { cursor += 1; break; }
186
+ if (source[cursor] === '/') {
187
+ if (source[cursor + 1] !== '>') fail(`expected '/>' in tag <${name}>`, cursor);
188
+ selfClosing = true;
189
+ cursor += 2;
190
+ break;
191
+ }
192
+ const attrStart = cursor;
193
+ while (cursor < length && !/[\s=/>]/.test(source[cursor])) cursor += 1;
194
+ const attrName = source.slice(attrStart, cursor);
195
+ if (!attrName) fail(`invalid attribute in tag <${name}>`, cursor);
196
+ while (cursor < length && /\s/.test(source[cursor])) cursor += 1;
197
+ if (source[cursor] === '=') {
198
+ cursor += 1;
199
+ while (cursor < length && /\s/.test(source[cursor])) cursor += 1;
200
+ if (cursor >= length) fail(`unterminated attribute ${attrName}`, cursor);
201
+ let value = '';
202
+ const quote = source[cursor];
203
+ if (quote === '"' || quote === "'") {
204
+ cursor += 1;
205
+ const valueStart = cursor;
206
+ while (cursor < length && source[cursor] !== quote) cursor += 1;
207
+ if (cursor >= length) fail(`unterminated attribute ${attrName}`, valueStart);
208
+ value = source.slice(valueStart, cursor);
209
+ cursor += 1;
210
+ } else {
211
+ const valueStart = cursor;
212
+ while (cursor < length && !/[\s/>]/.test(source[cursor])) cursor += 1;
213
+ value = source.slice(valueStart, cursor);
214
+ }
215
+ attrs[attrName] = decodeEntities(value);
216
+ } else {
217
+ attrs[attrName] = '';
218
+ }
219
+ }
220
+ return { name, attrs, selfClosing, end: cursor };
221
+ }
package/package.json CHANGED
@@ -1,6 +1 @@
1
- {
2
- "name": "dev-diagnosis-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {"author":"WJDdidi-1","bin":{"dev-diagnosis":"bin/dev-diagnosis.js"},"bugs":{"url":"https://github.com/WJDdidi-1/dev-doctor/issues"},"description":"One offline CLI that diagnoses a repo the way four small tools do: context noise for AI agents, missing CLI commands, build-cache value, and flaky tests.","engines":{"node":">=18"},"files":["bin/","lib/","examples/","README.md","LICENSE","CHANGELOG.md"],"homepage":"https://github.com/WJDdidi-1/dev-doctor#readme","keywords":["cli","diagnostics","ai-coding","context","build-cache","flaky-tests","path","developer-tools","offline"],"license":"Apache-2.0","name":"dev-diagnosis-cli","repository":{"type":"git","url":"git+https://github.com/WJDdidi-1/dev-doctor.git"},"scripts":{"example":"node examples/demo.mjs","test":"node --test"},"type":"module","version":"0.1.1"}