pmpt-cli 1.7.1 → 1.8.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/dist/commands/clone.js +3 -1
- package/dist/commands/diff.js +173 -0
- package/dist/commands/publish.js +36 -2
- package/dist/commands/status.js +40 -2
- package/dist/index.js +9 -0
- package/dist/lib/api.js +11 -0
- package/dist/lib/diff.js +143 -0
- package/dist/lib/quality.js +103 -0
- package/package.json +2 -1
package/dist/commands/clone.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname, resolve, sep } from 'path';
|
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
|
|
4
4
|
import { isInitialized, getConfigDir, getHistoryDir, getDocsDir, initializeProject } from '../lib/config.js';
|
|
5
5
|
import { validatePmptFile, isSafeFilename } from '../lib/pmptFile.js';
|
|
6
|
-
import { fetchPmptFile } from '../lib/api.js';
|
|
6
|
+
import { fetchPmptFile, trackClone } from '../lib/api.js';
|
|
7
7
|
/**
|
|
8
8
|
* Restore history from .pmpt data (shared with import command)
|
|
9
9
|
*/
|
|
@@ -123,6 +123,8 @@ export async function cmdClone(slug) {
|
|
|
123
123
|
versionCount = readdirSync(historyDir).filter((d) => d.startsWith('v')).length;
|
|
124
124
|
}
|
|
125
125
|
importSpinner.stop('Restore complete!');
|
|
126
|
+
// Track clone event (fire-and-forget)
|
|
127
|
+
trackClone(slug);
|
|
126
128
|
p.note([
|
|
127
129
|
`Project: ${pmptData.meta.projectName}`,
|
|
128
130
|
`Versions: ${versionCount}`,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { isInitialized, getDocsDir } from '../lib/config.js';
|
|
5
|
+
import { getAllSnapshots, resolveFullSnapshot } from '../lib/history.js';
|
|
6
|
+
import { diffSnapshots } from '../lib/diff.js';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import glob from 'fast-glob';
|
|
9
|
+
/** Read current .pmpt/docs/ as Record<filename, content>. */
|
|
10
|
+
function readWorkingCopy(projectPath) {
|
|
11
|
+
const docsDir = getDocsDir(projectPath);
|
|
12
|
+
const files = {};
|
|
13
|
+
if (!existsSync(docsDir))
|
|
14
|
+
return files;
|
|
15
|
+
const mdFiles = glob.sync('**/*.md', { cwd: docsDir });
|
|
16
|
+
for (const file of mdFiles) {
|
|
17
|
+
try {
|
|
18
|
+
files[file] = readFileSync(join(docsDir, file), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// skip unreadable
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
/** Format unified diff hunk header. */
|
|
27
|
+
function formatHunkHeader(hunk) {
|
|
28
|
+
const oldRange = hunk.oldCount === 1 ? `${hunk.oldStart}` : `${hunk.oldStart},${hunk.oldCount}`;
|
|
29
|
+
const newRange = hunk.newCount === 1 ? `${hunk.newStart}` : `${hunk.newStart},${hunk.newCount}`;
|
|
30
|
+
return `@@ -${oldRange} +${newRange} @@`;
|
|
31
|
+
}
|
|
32
|
+
/** Print one file's diff to stdout with colors. */
|
|
33
|
+
function printFileDiff(fd) {
|
|
34
|
+
console.log(pc.bold(`--- a/${fd.fileName}`));
|
|
35
|
+
console.log(pc.bold(`+++ b/${fd.fileName}`));
|
|
36
|
+
for (const hunk of fd.hunks) {
|
|
37
|
+
console.log(pc.cyan(formatHunkHeader(hunk)));
|
|
38
|
+
for (const line of hunk.lines) {
|
|
39
|
+
if (line.type === 'add')
|
|
40
|
+
console.log(pc.green(`+${line.content}`));
|
|
41
|
+
else if (line.type === 'remove')
|
|
42
|
+
console.log(pc.red(`-${line.content}`));
|
|
43
|
+
else
|
|
44
|
+
console.log(pc.dim(` ${line.content}`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
console.log('');
|
|
48
|
+
}
|
|
49
|
+
/** Print summary statistics. */
|
|
50
|
+
function printSummary(diffs) {
|
|
51
|
+
const modified = diffs.filter(d => d.status === 'modified').length;
|
|
52
|
+
const added = diffs.filter(d => d.status === 'added').length;
|
|
53
|
+
const removed = diffs.filter(d => d.status === 'removed').length;
|
|
54
|
+
const parts = [];
|
|
55
|
+
if (modified > 0)
|
|
56
|
+
parts.push(`${modified} modified`);
|
|
57
|
+
if (added > 0)
|
|
58
|
+
parts.push(`${added} added`);
|
|
59
|
+
if (removed > 0)
|
|
60
|
+
parts.push(`${removed} removed`);
|
|
61
|
+
let additions = 0;
|
|
62
|
+
let deletions = 0;
|
|
63
|
+
for (const fd of diffs) {
|
|
64
|
+
for (const hunk of fd.hunks) {
|
|
65
|
+
for (const line of hunk.lines) {
|
|
66
|
+
if (line.type === 'add')
|
|
67
|
+
additions++;
|
|
68
|
+
if (line.type === 'remove')
|
|
69
|
+
deletions++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
p.log.info(`${diffs.length} file(s) changed: ${parts.join(', ')}`);
|
|
74
|
+
p.log.info(`${pc.green(`+${additions}`)} additions, ${pc.red(`-${deletions}`)} deletions`);
|
|
75
|
+
}
|
|
76
|
+
export function cmdDiff(v1, v2, pathOrOptions, maybeOptions) {
|
|
77
|
+
// Commander passes args differently depending on how many positionals are given.
|
|
78
|
+
// pmpt diff v1 --file x → (v1, options)
|
|
79
|
+
// pmpt diff v1 v2 → (v1, v2, options)
|
|
80
|
+
// pmpt diff v1 v2 /path → (v1, v2, path, options)
|
|
81
|
+
let v2Str;
|
|
82
|
+
let path;
|
|
83
|
+
let options = {};
|
|
84
|
+
if (typeof v2 === 'object') {
|
|
85
|
+
options = v2;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
v2Str = v2;
|
|
89
|
+
if (typeof pathOrOptions === 'object') {
|
|
90
|
+
options = pathOrOptions;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
path = pathOrOptions;
|
|
94
|
+
if (maybeOptions)
|
|
95
|
+
options = maybeOptions;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const projectPath = path ? resolve(path) : process.cwd();
|
|
99
|
+
if (!isInitialized(projectPath)) {
|
|
100
|
+
p.log.error('Project not initialized. Run `pmpt init` first.');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
const fromVersion = parseInt(v1.replace(/^v/, ''), 10);
|
|
104
|
+
if (isNaN(fromVersion)) {
|
|
105
|
+
p.log.error('Invalid version format. Use: pmpt diff v1 v2');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const diffAgainstWorking = v2Str === undefined;
|
|
109
|
+
let toVersion;
|
|
110
|
+
if (!diffAgainstWorking) {
|
|
111
|
+
toVersion = parseInt(v2Str.replace(/^v/, ''), 10);
|
|
112
|
+
if (isNaN(toVersion)) {
|
|
113
|
+
p.log.error('Invalid version format. Use: pmpt diff v1 v2');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const snapshots = getAllSnapshots(projectPath);
|
|
118
|
+
if (snapshots.length === 0) {
|
|
119
|
+
p.log.error('No snapshots found.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const fromIndex = snapshots.findIndex(s => s.version === fromVersion);
|
|
123
|
+
if (fromIndex === -1) {
|
|
124
|
+
p.log.error(`Version v${fromVersion} not found.`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
let toIndex;
|
|
128
|
+
if (!diffAgainstWorking) {
|
|
129
|
+
toIndex = snapshots.findIndex(s => s.version === toVersion);
|
|
130
|
+
if (toIndex === -1) {
|
|
131
|
+
p.log.error(`Version v${toVersion} not found.`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Resolve file contents
|
|
136
|
+
const oldFiles = resolveFullSnapshot(snapshots, fromIndex);
|
|
137
|
+
const newFiles = diffAgainstWorking
|
|
138
|
+
? readWorkingCopy(projectPath)
|
|
139
|
+
: resolveFullSnapshot(snapshots, toIndex);
|
|
140
|
+
// Optional file filter
|
|
141
|
+
let filteredOld = oldFiles;
|
|
142
|
+
let filteredNew = newFiles;
|
|
143
|
+
if (options.file) {
|
|
144
|
+
filteredOld = oldFiles[options.file] !== undefined ? { [options.file]: oldFiles[options.file] } : {};
|
|
145
|
+
filteredNew = newFiles[options.file] !== undefined ? { [options.file]: newFiles[options.file] } : {};
|
|
146
|
+
if (Object.keys(filteredOld).length === 0 && Object.keys(filteredNew).length === 0) {
|
|
147
|
+
p.log.error(`File "${options.file}" not found in either version.`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const diffs = diffSnapshots(filteredOld, filteredNew);
|
|
152
|
+
const targetLabel = diffAgainstWorking ? 'working copy' : `v${toVersion}`;
|
|
153
|
+
p.intro(`pmpt diff v${fromVersion} → ${targetLabel}`);
|
|
154
|
+
if (diffs.length === 0) {
|
|
155
|
+
p.log.info('No differences found.');
|
|
156
|
+
p.outro('');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Changed files list
|
|
160
|
+
const fileList = diffs.map(d => {
|
|
161
|
+
const icon = d.status === 'added' ? pc.green('A')
|
|
162
|
+
: d.status === 'removed' ? pc.red('D')
|
|
163
|
+
: pc.yellow('M');
|
|
164
|
+
return ` ${icon} ${d.fileName}`;
|
|
165
|
+
}).join('\n');
|
|
166
|
+
p.note(fileList, 'Changed files');
|
|
167
|
+
console.log('');
|
|
168
|
+
for (const fd of diffs) {
|
|
169
|
+
printFileDiff(fd);
|
|
170
|
+
}
|
|
171
|
+
printSummary(diffs);
|
|
172
|
+
p.outro('');
|
|
173
|
+
}
|
package/dist/commands/publish.js
CHANGED
|
@@ -7,6 +7,8 @@ import { getPlanProgress } from '../lib/plan.js';
|
|
|
7
7
|
import { createPmptFile } from '../lib/pmptFile.js';
|
|
8
8
|
import { loadAuth } from '../lib/auth.js';
|
|
9
9
|
import { publishProject, fetchProjects } from '../lib/api.js';
|
|
10
|
+
import { computeQuality } from '../lib/quality.js';
|
|
11
|
+
import pc from 'picocolors';
|
|
10
12
|
import glob from 'fast-glob';
|
|
11
13
|
import { join } from 'path';
|
|
12
14
|
function readDocsFolder(docsDir) {
|
|
@@ -22,7 +24,7 @@ function readDocsFolder(docsDir) {
|
|
|
22
24
|
}
|
|
23
25
|
return files;
|
|
24
26
|
}
|
|
25
|
-
export async function cmdPublish(path) {
|
|
27
|
+
export async function cmdPublish(path, options) {
|
|
26
28
|
const projectPath = path ? resolve(path) : process.cwd();
|
|
27
29
|
if (!isInitialized(projectPath)) {
|
|
28
30
|
p.log.error('Project not initialized. Run `pmpt init` first.');
|
|
@@ -53,6 +55,39 @@ export async function cmdPublish(path) {
|
|
|
53
55
|
p.log.error('pmpt.md is empty. Run `pmpt plan` to generate content.');
|
|
54
56
|
process.exit(1);
|
|
55
57
|
}
|
|
58
|
+
// Quality gate
|
|
59
|
+
const docsDir = getDocsDir(projectPath);
|
|
60
|
+
const trackedFiles = glob.sync('**/*.md', { cwd: docsDir });
|
|
61
|
+
const hasGit = snapshots.some(s => !!s.git);
|
|
62
|
+
const quality = computeQuality({
|
|
63
|
+
pmptMd: pmptMdContent,
|
|
64
|
+
planAnswers: planProgress?.answers ?? null,
|
|
65
|
+
versionCount: snapshots.length,
|
|
66
|
+
docFiles: trackedFiles,
|
|
67
|
+
hasGit,
|
|
68
|
+
});
|
|
69
|
+
const gradeColor = quality.grade === 'A' ? pc.green
|
|
70
|
+
: quality.grade === 'B' ? pc.blue
|
|
71
|
+
: quality.grade === 'C' ? pc.yellow
|
|
72
|
+
: pc.red;
|
|
73
|
+
const qLines = [`Score: ${gradeColor(`${quality.score}/100`)} (Grade ${gradeColor(quality.grade)})`];
|
|
74
|
+
for (const item of quality.details) {
|
|
75
|
+
const icon = item.score === item.maxScore ? pc.green('✓') : pc.red('✗');
|
|
76
|
+
qLines.push(`${icon} ${item.label.padEnd(20)} ${item.score}/${item.maxScore}`);
|
|
77
|
+
}
|
|
78
|
+
p.note(qLines.join('\n'), 'Quality Score');
|
|
79
|
+
if (!quality.passesMinimum) {
|
|
80
|
+
const tips = quality.details.filter(d => d.tip).map(d => ` → ${d.tip}`);
|
|
81
|
+
p.log.warn(`Quality score ${quality.score}/100 is below minimum (40).`);
|
|
82
|
+
if (tips.length > 0) {
|
|
83
|
+
p.log.info('How to improve:\n' + tips.join('\n'));
|
|
84
|
+
}
|
|
85
|
+
if (!options?.force) {
|
|
86
|
+
p.log.error('Use `pmpt publish --force` to publish anyway.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
p.log.warn('Publishing with --force despite low quality score.');
|
|
90
|
+
}
|
|
56
91
|
const projectName = planProgress?.answers?.projectName || basename(projectPath);
|
|
57
92
|
// Try to load existing published data for prefill
|
|
58
93
|
let existing;
|
|
@@ -128,7 +163,6 @@ export async function cmdPublish(path) {
|
|
|
128
163
|
files: resolveFullSnapshot(snapshots, i),
|
|
129
164
|
git: snapshot.git,
|
|
130
165
|
}));
|
|
131
|
-
const docsDir = getDocsDir(projectPath);
|
|
132
166
|
const docs = readDocsFolder(docsDir);
|
|
133
167
|
const meta = {
|
|
134
168
|
projectName,
|
package/dist/commands/status.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
import {
|
|
2
|
+
import { resolve, join } from 'path';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { isInitialized, loadConfig, getDocsDir } from '../lib/config.js';
|
|
4
5
|
import { getTrackedFiles, getAllSnapshots } from '../lib/history.js';
|
|
6
|
+
import { getPlanProgress } from '../lib/plan.js';
|
|
7
|
+
import { computeQuality } from '../lib/quality.js';
|
|
8
|
+
import pc from 'picocolors';
|
|
5
9
|
export function cmdStatus(path) {
|
|
6
10
|
const projectPath = path ? resolve(path) : process.cwd();
|
|
7
11
|
if (!isInitialized(projectPath)) {
|
|
@@ -31,5 +35,39 @@ export function cmdStatus(path) {
|
|
|
31
35
|
notes.push(' (none - start with pmpt plan)');
|
|
32
36
|
}
|
|
33
37
|
p.note(notes.join('\n'), 'Project Info');
|
|
38
|
+
// Quality Score
|
|
39
|
+
const docsDir = getDocsDir(projectPath);
|
|
40
|
+
const pmptMdPath = join(docsDir, 'pmpt.md');
|
|
41
|
+
const pmptMd = existsSync(pmptMdPath) ? readFileSync(pmptMdPath, 'utf-8') : null;
|
|
42
|
+
const planProgress = getPlanProgress(projectPath);
|
|
43
|
+
const hasGit = snapshots.some(s => !!s.git);
|
|
44
|
+
const quality = computeQuality({
|
|
45
|
+
pmptMd,
|
|
46
|
+
planAnswers: planProgress?.answers ?? null,
|
|
47
|
+
versionCount: snapshots.length,
|
|
48
|
+
docFiles: tracked,
|
|
49
|
+
hasGit,
|
|
50
|
+
});
|
|
51
|
+
const gradeColor = quality.grade === 'A' ? pc.green
|
|
52
|
+
: quality.grade === 'B' ? pc.blue
|
|
53
|
+
: quality.grade === 'C' ? pc.yellow
|
|
54
|
+
: pc.red;
|
|
55
|
+
const qLines = [
|
|
56
|
+
`Score: ${gradeColor(`${quality.score}/100`)} (Grade ${gradeColor(quality.grade)})`,
|
|
57
|
+
'',
|
|
58
|
+
];
|
|
59
|
+
for (const item of quality.details) {
|
|
60
|
+
const icon = item.score === item.maxScore ? pc.green('✓') : pc.red('✗');
|
|
61
|
+
const scoreStr = `${item.score}/${item.maxScore}`.padStart(5);
|
|
62
|
+
qLines.push(`${icon} ${item.label.padEnd(20)} ${scoreStr}`);
|
|
63
|
+
}
|
|
64
|
+
const tips = quality.details.filter(d => d.tip).map(d => d.tip);
|
|
65
|
+
if (tips.length > 0) {
|
|
66
|
+
qLines.push('');
|
|
67
|
+
for (const tip of tips) {
|
|
68
|
+
qLines.push(`${pc.dim('→')} ${tip}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
p.note(qLines.join('\n'), 'Quality Score');
|
|
34
72
|
p.outro('');
|
|
35
73
|
}
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { cmdUnpublish } from './commands/unpublish.js';
|
|
|
16
16
|
import { cmdClone } from './commands/clone.js';
|
|
17
17
|
import { cmdBrowse } from './commands/browse.js';
|
|
18
18
|
import { cmdRecover } from './commands/recover.js';
|
|
19
|
+
import { cmdDiff } from './commands/diff.js';
|
|
19
20
|
const program = new Command();
|
|
20
21
|
program
|
|
21
22
|
.name('pmpt')
|
|
@@ -28,6 +29,8 @@ Examples:
|
|
|
28
29
|
$ pmpt save Save snapshot of docs folder
|
|
29
30
|
$ pmpt watch Auto-detect file changes
|
|
30
31
|
$ pmpt history View version history
|
|
32
|
+
$ pmpt diff v1 v2 Compare two versions
|
|
33
|
+
$ pmpt diff v3 Compare v3 to working copy
|
|
31
34
|
$ pmpt squash v2 v5 Merge versions v2-v5 into v2
|
|
32
35
|
$ pmpt export Export as .pmpt file (single JSON)
|
|
33
36
|
$ pmpt import <file.pmpt> Import from .pmpt file
|
|
@@ -62,6 +65,11 @@ program
|
|
|
62
65
|
.description('View saved version history')
|
|
63
66
|
.option('-c, --compact', 'Show compact history (hide small changes)')
|
|
64
67
|
.action(cmdHistory);
|
|
68
|
+
program
|
|
69
|
+
.command('diff <v1> [v2] [path]')
|
|
70
|
+
.description('Compare two versions (or version vs working copy)')
|
|
71
|
+
.option('-f, --file <name>', 'Compare specific file only')
|
|
72
|
+
.action(cmdDiff);
|
|
65
73
|
program
|
|
66
74
|
.command('squash <from> <to> [path]')
|
|
67
75
|
.description('Squash multiple versions into one (e.g., pmpt squash v2 v5)')
|
|
@@ -97,6 +105,7 @@ program
|
|
|
97
105
|
program
|
|
98
106
|
.command('publish [path]')
|
|
99
107
|
.description('Publish project to pmptwiki platform')
|
|
108
|
+
.option('--force', 'Publish even if quality score is below minimum')
|
|
100
109
|
.action(cmdPublish);
|
|
101
110
|
program
|
|
102
111
|
.command('edit')
|
package/dist/lib/api.js
CHANGED
|
@@ -81,3 +81,14 @@ export async function fetchPmptFile(slug) {
|
|
|
81
81
|
}
|
|
82
82
|
return res.text();
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Fire-and-forget clone tracking ping.
|
|
86
|
+
* Never throws — failures are silently ignored.
|
|
87
|
+
*/
|
|
88
|
+
export function trackClone(slug) {
|
|
89
|
+
fetch(`${API_BASE}/metrics/clone`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ slug }),
|
|
93
|
+
}).catch(() => { });
|
|
94
|
+
}
|
package/dist/lib/diff.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple unified diff implementation using LCS (Longest Common Subsequence).
|
|
3
|
+
* Pure functions — no file I/O, no UI.
|
|
4
|
+
*/
|
|
5
|
+
/** LCS DP table — O(n*m), fine for markdown files (<500 lines). */
|
|
6
|
+
function lcsTable(a, b) {
|
|
7
|
+
const m = a.length;
|
|
8
|
+
const n = b.length;
|
|
9
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
10
|
+
for (let i = 1; i <= m; i++) {
|
|
11
|
+
for (let j = 1; j <= n; j++) {
|
|
12
|
+
if (a[i - 1] === b[j - 1]) {
|
|
13
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return dp;
|
|
21
|
+
}
|
|
22
|
+
/** Backtrack LCS table → ordered DiffLine array. */
|
|
23
|
+
function backtrack(dp, a, b) {
|
|
24
|
+
const result = [];
|
|
25
|
+
let i = a.length;
|
|
26
|
+
let j = b.length;
|
|
27
|
+
while (i > 0 || j > 0) {
|
|
28
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
29
|
+
result.push({ type: 'context', content: a[i - 1] });
|
|
30
|
+
i--;
|
|
31
|
+
j--;
|
|
32
|
+
}
|
|
33
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
34
|
+
result.push({ type: 'add', content: b[j - 1] });
|
|
35
|
+
j--;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
result.push({ type: 'remove', content: a[i - 1] });
|
|
39
|
+
i--;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result.reverse();
|
|
43
|
+
}
|
|
44
|
+
/** Build a DiffHunk from a slice of diff lines. */
|
|
45
|
+
function buildHunk(lines, start, end) {
|
|
46
|
+
const hunkLines = lines.slice(start, end + 1);
|
|
47
|
+
let oldLine = 1;
|
|
48
|
+
let newLine = 1;
|
|
49
|
+
for (let i = 0; i < start; i++) {
|
|
50
|
+
if (lines[i].type === 'context' || lines[i].type === 'remove')
|
|
51
|
+
oldLine++;
|
|
52
|
+
if (lines[i].type === 'context' || lines[i].type === 'add')
|
|
53
|
+
newLine++;
|
|
54
|
+
}
|
|
55
|
+
let oldCount = 0;
|
|
56
|
+
let newCount = 0;
|
|
57
|
+
for (const line of hunkLines) {
|
|
58
|
+
if (line.type === 'context' || line.type === 'remove')
|
|
59
|
+
oldCount++;
|
|
60
|
+
if (line.type === 'context' || line.type === 'add')
|
|
61
|
+
newCount++;
|
|
62
|
+
}
|
|
63
|
+
return { oldStart: oldLine, oldCount, newStart: newLine, newCount, lines: hunkLines };
|
|
64
|
+
}
|
|
65
|
+
/** Group diff lines into hunks with context (default 3 lines, like git). */
|
|
66
|
+
function groupIntoHunks(lines, contextLines = 3) {
|
|
67
|
+
const changeIndices = [];
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
if (lines[i].type !== 'context')
|
|
70
|
+
changeIndices.push(i);
|
|
71
|
+
}
|
|
72
|
+
if (changeIndices.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const hunks = [];
|
|
75
|
+
let hunkStart = Math.max(0, changeIndices[0] - contextLines);
|
|
76
|
+
let hunkEnd = Math.min(lines.length - 1, changeIndices[0] + contextLines);
|
|
77
|
+
for (let k = 1; k < changeIndices.length; k++) {
|
|
78
|
+
const nextStart = Math.max(0, changeIndices[k] - contextLines);
|
|
79
|
+
const nextEnd = Math.min(lines.length - 1, changeIndices[k] + contextLines);
|
|
80
|
+
if (nextStart <= hunkEnd + 1) {
|
|
81
|
+
hunkEnd = nextEnd;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
hunks.push(buildHunk(lines, hunkStart, hunkEnd));
|
|
85
|
+
hunkStart = nextStart;
|
|
86
|
+
hunkEnd = nextEnd;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
hunks.push(buildHunk(lines, hunkStart, hunkEnd));
|
|
90
|
+
return hunks;
|
|
91
|
+
}
|
|
92
|
+
/** Normalize trailing newline from split. */
|
|
93
|
+
function splitLines(content) {
|
|
94
|
+
const lines = content.split('\n');
|
|
95
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
96
|
+
lines.pop();
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
/** Compute unified diff hunks between two strings. */
|
|
100
|
+
export function computeDiff(oldContent, newContent) {
|
|
101
|
+
const oldLines = splitLines(oldContent);
|
|
102
|
+
const newLines = splitLines(newContent);
|
|
103
|
+
const dp = lcsTable(oldLines, newLines);
|
|
104
|
+
const diffLines = backtrack(dp, oldLines, newLines);
|
|
105
|
+
return groupIntoHunks(diffLines);
|
|
106
|
+
}
|
|
107
|
+
/** Diff a single file between two versions. */
|
|
108
|
+
export function diffFile(fileName, oldContent, newContent) {
|
|
109
|
+
if (oldContent === null && newContent === null) {
|
|
110
|
+
return { fileName, status: 'unchanged', hunks: [] };
|
|
111
|
+
}
|
|
112
|
+
if (oldContent === null) {
|
|
113
|
+
const lines = splitLines(newContent).map(l => ({ type: 'add', content: l }));
|
|
114
|
+
return {
|
|
115
|
+
fileName,
|
|
116
|
+
status: 'added',
|
|
117
|
+
hunks: lines.length > 0 ? [{ oldStart: 0, oldCount: 0, newStart: 1, newCount: lines.length, lines }] : [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (newContent === null) {
|
|
121
|
+
const lines = splitLines(oldContent).map(l => ({ type: 'remove', content: l }));
|
|
122
|
+
return {
|
|
123
|
+
fileName,
|
|
124
|
+
status: 'removed',
|
|
125
|
+
hunks: lines.length > 0 ? [{ oldStart: 1, oldCount: lines.length, newStart: 0, newCount: 0, lines }] : [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (oldContent === newContent) {
|
|
129
|
+
return { fileName, status: 'unchanged', hunks: [] };
|
|
130
|
+
}
|
|
131
|
+
return { fileName, status: 'modified', hunks: computeDiff(oldContent, newContent) };
|
|
132
|
+
}
|
|
133
|
+
/** Diff all files between two snapshots (Record<filename, content>). */
|
|
134
|
+
export function diffSnapshots(oldFiles, newFiles) {
|
|
135
|
+
const allNames = new Set([...Object.keys(oldFiles), ...Object.keys(newFiles)]);
|
|
136
|
+
const diffs = [];
|
|
137
|
+
for (const name of [...allNames].sort()) {
|
|
138
|
+
const fd = diffFile(name, oldFiles[name] ?? null, newFiles[name] ?? null);
|
|
139
|
+
if (fd.status !== 'unchanged')
|
|
140
|
+
diffs.push(fd);
|
|
141
|
+
}
|
|
142
|
+
return diffs;
|
|
143
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project quality score calculation.
|
|
3
|
+
* Pure functions — no I/O, reusable in CLI and API.
|
|
4
|
+
*/
|
|
5
|
+
const MIN_PUBLISH_SCORE = 40;
|
|
6
|
+
export function computeQuality(data) {
|
|
7
|
+
const details = [];
|
|
8
|
+
// 1. pmpt.md content (30 points)
|
|
9
|
+
{
|
|
10
|
+
let score = 0;
|
|
11
|
+
let tip;
|
|
12
|
+
const len = data.pmptMd?.trim().length ?? 0;
|
|
13
|
+
if (len > 0)
|
|
14
|
+
score += 10;
|
|
15
|
+
if (len >= 200)
|
|
16
|
+
score += 10;
|
|
17
|
+
if (len >= 500)
|
|
18
|
+
score += 10;
|
|
19
|
+
if (score < 30) {
|
|
20
|
+
tip = len === 0
|
|
21
|
+
? 'Run `pmpt plan` to generate pmpt.md'
|
|
22
|
+
: `pmpt.md is ${len} chars — expand to 500+ for full score`;
|
|
23
|
+
}
|
|
24
|
+
details.push({ label: 'pmpt.md content', score, maxScore: 30, tip });
|
|
25
|
+
}
|
|
26
|
+
// 2. Plan completeness (25 points)
|
|
27
|
+
{
|
|
28
|
+
let score = 0;
|
|
29
|
+
let tip;
|
|
30
|
+
if (data.planAnswers?.productIdea?.trim())
|
|
31
|
+
score += 10;
|
|
32
|
+
if (data.planAnswers?.coreFeatures?.trim())
|
|
33
|
+
score += 10;
|
|
34
|
+
if (data.planAnswers?.techStack?.trim())
|
|
35
|
+
score += 5;
|
|
36
|
+
if (score < 25) {
|
|
37
|
+
const missing = [];
|
|
38
|
+
if (!data.planAnswers?.productIdea?.trim())
|
|
39
|
+
missing.push('product idea');
|
|
40
|
+
if (!data.planAnswers?.coreFeatures?.trim())
|
|
41
|
+
missing.push('core features');
|
|
42
|
+
if (!data.planAnswers?.techStack?.trim())
|
|
43
|
+
missing.push('tech stack');
|
|
44
|
+
tip = `Complete plan: add ${missing.join(', ')}`;
|
|
45
|
+
}
|
|
46
|
+
details.push({ label: 'Plan completeness', score, maxScore: 25, tip });
|
|
47
|
+
}
|
|
48
|
+
// 3. Version history (20 points)
|
|
49
|
+
{
|
|
50
|
+
let score = 0;
|
|
51
|
+
let tip;
|
|
52
|
+
if (data.versionCount >= 2)
|
|
53
|
+
score += 10;
|
|
54
|
+
if (data.versionCount >= 3)
|
|
55
|
+
score += 10;
|
|
56
|
+
if (score < 20) {
|
|
57
|
+
tip = data.versionCount < 2
|
|
58
|
+
? 'Save more versions with `pmpt save`'
|
|
59
|
+
: 'One more version for full score';
|
|
60
|
+
}
|
|
61
|
+
details.push({ label: 'Version history', score, maxScore: 20, tip });
|
|
62
|
+
}
|
|
63
|
+
// 4. Documentation files (15 points)
|
|
64
|
+
{
|
|
65
|
+
let score = 0;
|
|
66
|
+
let tip;
|
|
67
|
+
if (data.docFiles.includes('plan.md'))
|
|
68
|
+
score += 5;
|
|
69
|
+
if (data.docFiles.includes('pmpt.md'))
|
|
70
|
+
score += 5;
|
|
71
|
+
if (data.docFiles.length > 2)
|
|
72
|
+
score += 5;
|
|
73
|
+
if (score < 15) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
if (!data.docFiles.includes('plan.md'))
|
|
76
|
+
missing.push('plan.md');
|
|
77
|
+
if (!data.docFiles.includes('pmpt.md'))
|
|
78
|
+
missing.push('pmpt.md');
|
|
79
|
+
if (data.docFiles.length <= 2)
|
|
80
|
+
missing.push('additional docs');
|
|
81
|
+
tip = `Add: ${missing.join(', ')}`;
|
|
82
|
+
}
|
|
83
|
+
details.push({ label: 'Documentation', score, maxScore: 15, tip });
|
|
84
|
+
}
|
|
85
|
+
// 5. Git integration (10 points)
|
|
86
|
+
{
|
|
87
|
+
let score = 0;
|
|
88
|
+
let tip;
|
|
89
|
+
if (data.hasGit)
|
|
90
|
+
score = 10;
|
|
91
|
+
else
|
|
92
|
+
tip = 'Initialize git repo for commit tracking';
|
|
93
|
+
details.push({ label: 'Git integration', score, maxScore: 10, tip });
|
|
94
|
+
}
|
|
95
|
+
const total = details.reduce((sum, d) => sum + d.score, 0);
|
|
96
|
+
const grade = total >= 80 ? 'A' : total >= 60 ? 'B' : total >= 40 ? 'C' : total >= 20 ? 'D' : 'F';
|
|
97
|
+
return {
|
|
98
|
+
score: total,
|
|
99
|
+
details,
|
|
100
|
+
grade,
|
|
101
|
+
passesMinimum: total >= MIN_PUBLISH_SCORE,
|
|
102
|
+
};
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmpt-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Record and share your AI-driven product development journey",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"commander": "^12.0.0",
|
|
40
40
|
"fast-glob": "^3.3.0",
|
|
41
41
|
"open": "^11.0.0",
|
|
42
|
+
"picocolors": "^1.0.0",
|
|
42
43
|
"zod": "^3.22.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|