@llman-sdd/core 0.1.3 → 0.2.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/package.json +1 -1
- package/src/archive/freeze.ts +14 -9
- package/src/archive/sevenzip.ts +34 -5
- package/src/change/id.ts +51 -31
- package/src/change/lifecycle.ts +178 -23
- package/src/change/nextId.ts +55 -0
- package/src/config/changeId.ts +87 -0
- package/src/config/surface.ts +59 -0
- package/src/git/spawnGit.ts +12 -0
- package/src/index.ts +53 -2
- package/src/report/collect.ts +30 -14
- package/src/report/graph.ts +339 -43
- package/src/report/show.ts +32 -13
- package/src/report/specHelpers.ts +13 -5
- package/src/review/review.ts +34 -15
- package/src/spec/authoring.ts +206 -0
- package/src/templates/embedded.ts +13 -12
- package/src/validation/changeCheck.ts +310 -0
- package/src/validation/staleness.ts +158 -0
- package/src/validation/validate.ts +117 -42
package/src/report/show.ts
CHANGED
|
@@ -41,8 +41,16 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
41
41
|
? countTasks(io.readText(`${dir}/tasks.md`))
|
|
42
42
|
: { completed: 0, total: 0 };
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// v1 parity: show works (gracefully degraded gates) outside a git repo.
|
|
45
|
+
let cleanTree = false;
|
|
46
|
+
let current = null;
|
|
47
|
+
try {
|
|
48
|
+
cleanTree = isCleanTree(git);
|
|
49
|
+
current = currentBranch(git);
|
|
50
|
+
} catch {
|
|
51
|
+
current = null;
|
|
52
|
+
}
|
|
53
|
+
const onBoundBranch = binding !== null && current === binding.branch;
|
|
46
54
|
|
|
47
55
|
// specs landing: bound changes must have touched llmanspec/specs/ since base
|
|
48
56
|
let specsLanded = false;
|
|
@@ -60,7 +68,17 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
60
68
|
const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
|
|
61
69
|
const validateOk = !specReport.failed && (total === 0 || tasksDone);
|
|
62
70
|
|
|
63
|
-
const
|
|
71
|
+
const stageHint = !hasDesign
|
|
72
|
+
? 'add design.md (current: draft → designed)'
|
|
73
|
+
: !hasTasks
|
|
74
|
+
? 'add tasks.md (current: designed → planned)'
|
|
75
|
+
: stage === 'planned'
|
|
76
|
+
? 'bind via `llman sdd change start <id>` (planned → full)'
|
|
77
|
+
: '';
|
|
78
|
+
const tasksHint =
|
|
79
|
+
total > 0 && completed < total
|
|
80
|
+
? `${total - completed} unchecked tasks`
|
|
81
|
+
: 'complete or check off remaining tasks';
|
|
64
82
|
|
|
65
83
|
const gateChecks = [
|
|
66
84
|
{
|
|
@@ -71,29 +89,30 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
|
|
|
71
89
|
{
|
|
72
90
|
name: 'on-bound-branch',
|
|
73
91
|
pass: onBoundBranch,
|
|
74
|
-
hint: onBoundBranch ? '' : 'change is not attached; run `llman
|
|
92
|
+
hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
|
|
75
93
|
},
|
|
76
94
|
{
|
|
77
95
|
name: 'stage-complete',
|
|
78
|
-
pass:
|
|
79
|
-
hint:
|
|
96
|
+
pass: hasDesign && hasTasks,
|
|
97
|
+
hint: hasDesign && hasTasks ? '' : (stageHint as string),
|
|
80
98
|
},
|
|
81
99
|
{
|
|
82
100
|
name: 'specs-landed',
|
|
83
|
-
pass: specsLanded,
|
|
84
|
-
hint:
|
|
85
|
-
|
|
86
|
-
|
|
101
|
+
pass: specsLanded || !needsSpecsChange,
|
|
102
|
+
hint:
|
|
103
|
+
specsLanded || !needsSpecsChange
|
|
104
|
+
? ''
|
|
105
|
+
: 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
|
|
87
106
|
},
|
|
88
107
|
{
|
|
89
108
|
name: 'tasks-done',
|
|
90
|
-
pass: tasksDone,
|
|
91
|
-
hint: tasksDone ? '' :
|
|
109
|
+
pass: tasksDone && total > 0,
|
|
110
|
+
hint: tasksDone && total > 0 ? '' : tasksHint,
|
|
92
111
|
},
|
|
93
112
|
{
|
|
94
113
|
name: 'validate',
|
|
95
114
|
pass: validateOk,
|
|
96
|
-
hint: validateOk ? '' : 'fix issues reported by `llman
|
|
115
|
+
hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
|
|
97
116
|
},
|
|
98
117
|
];
|
|
99
118
|
|
|
@@ -14,9 +14,12 @@ export interface SpecHelperIo {
|
|
|
14
14
|
listDir(path: string): string[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* v1 parity (`req_registry.rs::next_req_id_from_index`): smallest free rN over
|
|
19
|
+
* the RULE (@human) req ids only; acceptance-only req tags do not occupy ids.
|
|
20
|
+
*/
|
|
18
21
|
export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
19
|
-
|
|
22
|
+
const used = new Set<number>();
|
|
20
23
|
const walk = (dir: string): void => {
|
|
21
24
|
if (!io.exists(dir)) return;
|
|
22
25
|
for (const name of io.listDir(dir)) {
|
|
@@ -24,8 +27,10 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
24
27
|
if (name.endsWith('.feature')) {
|
|
25
28
|
const doc = parseCapability(io.readText(full), full);
|
|
26
29
|
for (const scenario of doc.scenarios) {
|
|
30
|
+
if (scenario.classification !== 'human') continue;
|
|
27
31
|
for (const reqId of scenario.reqIds) {
|
|
28
|
-
|
|
32
|
+
const n = Math.trunc(Number(reqId.replace(/^r/u, '')));
|
|
33
|
+
if (Number.isFinite(n)) used.add(n);
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
} else if (io.isDirectory(full)) {
|
|
@@ -34,7 +39,9 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
|
|
|
34
39
|
}
|
|
35
40
|
};
|
|
36
41
|
walk(specsDir);
|
|
37
|
-
|
|
42
|
+
let n = 1;
|
|
43
|
+
while (used.has(n)) n += 1;
|
|
44
|
+
return `r${n}`;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export function skeletonContent(capability: string, reqId: string, locale: string): string {
|
|
@@ -53,10 +60,11 @@ export function scaffoldSpec(
|
|
|
53
60
|
specsDir: string,
|
|
54
61
|
capability: string,
|
|
55
62
|
locale: string,
|
|
63
|
+
opts: { force?: boolean } = {},
|
|
56
64
|
): string {
|
|
57
65
|
const reqId = nextReqId(io, specsDir);
|
|
58
66
|
const path = `${specsDir}/${capability}.feature`;
|
|
59
|
-
if (io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
67
|
+
if (!opts.force && io.exists(path)) throw new Error(`spec already exists: ${path}`);
|
|
60
68
|
io.mkdirp('src/');
|
|
61
69
|
io.writeText(path, skeletonContent(capability, reqId, locale));
|
|
62
70
|
return path;
|
package/src/review/review.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { GitLike } from '../git/spawnGit.ts';
|
|
2
|
+
import { evaluateStaleness, notApplicableStaleness } from '../validation/staleness.ts';
|
|
1
3
|
/**
|
|
2
4
|
* Review aggregation (review-freeze capability, r23): five-signal review over
|
|
3
5
|
* spec IR + validate sweep. Port of v1 sdd/review.rs observable contract.
|
|
@@ -26,6 +28,12 @@ export interface ReviewInput {
|
|
|
26
28
|
boundChangeCount: number;
|
|
27
29
|
/** Active change summaries for the strict sweep (pending tasks → FAIL). */
|
|
28
30
|
activeChanges?: readonly { name: string; completedTasks: number; totalTasks: number }[];
|
|
31
|
+
/** Restrict per-capability signals (pending/manual/unbound/stale) to this capability. */
|
|
32
|
+
capability?: string;
|
|
33
|
+
/** git + root for real staleness evaluation (v1 parity). */
|
|
34
|
+
git?: GitLike;
|
|
35
|
+
root?: string;
|
|
36
|
+
specsDir?: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface ReviewResult {
|
|
@@ -35,17 +43,8 @@ export interface ReviewResult {
|
|
|
35
43
|
exitCode: number;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
function executableBound(
|
|
39
|
-
scenarioTags: readonly string[],
|
|
40
|
-
bindings: readonly TagBinding[] | null,
|
|
41
|
-
): boolean {
|
|
42
|
-
if (!bindings || bindings.length === 0) return false;
|
|
43
|
-
const has = (t: string): boolean => scenarioTags.includes(t);
|
|
44
|
-
return bindings.some((b) => (b.kind === 'tags' ? b.tags.every((t) => has(t)) : false));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
46
|
export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
48
|
-
const { entries,
|
|
47
|
+
const { entries, boundChangeCount } = input;
|
|
49
48
|
const strictChangeFails = (input.activeChanges ?? []).filter(
|
|
50
49
|
(c) => c.totalTasks > 0 && c.completedTasks < c.totalTasks,
|
|
51
50
|
);
|
|
@@ -59,18 +58,38 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
59
58
|
|
|
60
59
|
for (const entry of sorted) {
|
|
61
60
|
const cap = entry.doc.header.capability ?? entry.fileName;
|
|
61
|
+
// r33: per-capability signals honor the --capability filter; locked and
|
|
62
|
+
// validate stay global regardless.
|
|
63
|
+
if (input.capability !== undefined && cap !== input.capability) continue;
|
|
62
64
|
const rules = entry.doc.scenarios.filter((s) => s.classification === 'human');
|
|
63
65
|
const acceptance = entry.doc.scenarios.filter((s) => s.classification === 'executable');
|
|
64
66
|
const acceptanceReqIds = new Set(acceptance.flatMap((s) => s.reqIds));
|
|
65
67
|
const pending = rules.filter((r) => !r.reqIds.some((id) => acceptanceReqIds.has(id)));
|
|
66
68
|
const manual = rules.filter((r) => r.manual);
|
|
67
|
-
|
|
69
|
+
// v1 r5: unbound = orphan acceptance scenarios (no @req link).
|
|
70
|
+
const unbound = acceptance.filter((s) => s.reqIds.length === 0);
|
|
68
71
|
|
|
69
72
|
push('pending', cap, pending.length);
|
|
70
73
|
push('manual', cap, manual.length);
|
|
71
74
|
push('unbound', cap, unbound.length);
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
|
|
76
|
+
// staleness (v1 evaluate): real base-ref/scope evaluation.
|
|
77
|
+
let staleInfo = notApplicableStaleness();
|
|
78
|
+
let staleCount = 0;
|
|
79
|
+
if (input.git !== undefined && input.root !== undefined) {
|
|
80
|
+
const specRel =
|
|
81
|
+
(entry.fileName.startsWith('llmanspec/') ? '' : 'llmanspec/specs/') + entry.fileName;
|
|
82
|
+
const evalResult = evaluateStaleness({
|
|
83
|
+
git: input.git,
|
|
84
|
+
root: input.root,
|
|
85
|
+
specRel,
|
|
86
|
+
scope: entry.doc.header.scope?.split(',').map((x) => x.trim()) ?? [],
|
|
87
|
+
baseRefEnv: process.env.LLMANSPEC_BASE_REF,
|
|
88
|
+
});
|
|
89
|
+
staleInfo = evalResult.info;
|
|
90
|
+
staleCount = staleInfo.status === 'OK' || staleInfo.status === 'NOTAPPLICABLE' ? 0 : 1;
|
|
91
|
+
}
|
|
92
|
+
push('stale', cap, staleCount, staleInfo.status === 'NOTAPPLICABLE' ? '' : staleInfo.status);
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
const failed = sweep.verdicts.filter((v) => !v.ok);
|
|
@@ -96,13 +115,13 @@ export function buildReview(input: ReviewInput, io: SpecIo): ReviewResult {
|
|
|
96
115
|
'locked',
|
|
97
116
|
'-',
|
|
98
117
|
0,
|
|
99
|
-
`${boundChangeCount} bound change(s); inspect with \`llman
|
|
118
|
+
`${boundChangeCount} bound change(s); inspect with \`llman sdd change diff <id>\``,
|
|
100
119
|
);
|
|
101
120
|
push(
|
|
102
121
|
'validate',
|
|
103
122
|
'-',
|
|
104
123
|
failed.length,
|
|
105
|
-
failed.length > 0 ? 'validate --all failed; run `llman
|
|
124
|
+
failed.length > 0 ? 'validate --all failed; run `llman sdd validate --all` for details' : 'ok',
|
|
106
125
|
);
|
|
107
126
|
|
|
108
127
|
const warningCount = signals
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programming-style spec authoring helpers (r41-r43, v1 parity):
|
|
3
|
+
* append rules / acceptance scenarios, resolve req ids, dedupe conflicts.
|
|
4
|
+
* Appends are text-level so existing file content (formatting, comments)
|
|
5
|
+
* stays untouched.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapabilityDoc } from './ir.ts';
|
|
9
|
+
|
|
10
|
+
const RULE_KEYWORDS = ['MUST', 'SHALL', '必须', '不得', '禁止'] as const;
|
|
11
|
+
|
|
12
|
+
export class AuthoringError extends Error {}
|
|
13
|
+
|
|
14
|
+
export interface SpecEntryLike {
|
|
15
|
+
fileName: string;
|
|
16
|
+
doc: CapabilityDoc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WriteIo {
|
|
20
|
+
exists(path: string): boolean;
|
|
21
|
+
readText(path: string): string;
|
|
22
|
+
writeText(path: string, content: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keywordsOf(content: string): {
|
|
26
|
+
scenario: string;
|
|
27
|
+
given: string;
|
|
28
|
+
when: string;
|
|
29
|
+
thenText: string;
|
|
30
|
+
} {
|
|
31
|
+
return content.includes('功能:')
|
|
32
|
+
? { scenario: '场景', given: '假如', when: '当', thenText: '那么' }
|
|
33
|
+
: { scenario: 'Scenario', given: 'Given', when: 'When', thenText: 'Then' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertRuleWording(statement: string): void {
|
|
37
|
+
if (!RULE_KEYWORDS.some((k) => statement.includes(k))) {
|
|
38
|
+
throw new AuthoringError(
|
|
39
|
+
`statement must contain a rule keyword (${RULE_KEYWORDS.join('/')}): ${statement}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findReq(
|
|
45
|
+
entries: readonly SpecEntryLike[],
|
|
46
|
+
reqId: string,
|
|
47
|
+
): { entry: SpecEntryLike; scenarioName: string; statement: string } | null {
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
for (const scenario of entry.doc.scenarios) {
|
|
50
|
+
if (scenario.reqIds.includes(reqId) && scenario.classification === 'human') {
|
|
51
|
+
return { entry, scenarioName: scenario.name, statement: scenario.statement };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** v1 parity: only @human (rule) req ids participate in the dedupe registry. */
|
|
59
|
+
export function ruleReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
60
|
+
const ids = new Set<string>();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
for (const scenario of entry.doc.scenarios) {
|
|
63
|
+
if (scenario.classification === 'human') {
|
|
64
|
+
for (const id of scenario.reqIds) ids.add(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function allReqIds(entries: readonly SpecEntryLike[]): Set<string> {
|
|
72
|
+
const ids = new Set<string>();
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
for (const scenario of entry.doc.scenarios) {
|
|
75
|
+
for (const id of scenario.reqIds) ids.add(id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return ids;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AddReqOpts {
|
|
82
|
+
capability: string;
|
|
83
|
+
reqId: string;
|
|
84
|
+
title: string;
|
|
85
|
+
statement: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Append `@req:<id> @human` rule scenario to `<capability>.feature` (r41). */
|
|
89
|
+
export function addReq(
|
|
90
|
+
io: WriteIo,
|
|
91
|
+
specsRoot: string,
|
|
92
|
+
entries: readonly SpecEntryLike[],
|
|
93
|
+
opts: AddReqOpts,
|
|
94
|
+
): string {
|
|
95
|
+
const path = `${specsRoot}/${opts.capability}.feature`;
|
|
96
|
+
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
97
|
+
if (allReqIds(entries).has(opts.reqId)) {
|
|
98
|
+
throw new AuthoringError(`req id already in use: ${opts.reqId}`);
|
|
99
|
+
}
|
|
100
|
+
assertRuleWording(opts.statement);
|
|
101
|
+
const kw = keywordsOf(io.readText(path));
|
|
102
|
+
const block = `\n @req:${opts.reqId} @human\n ${kw.scenario}: ${opts.title}\n ${opts.statement}\n`;
|
|
103
|
+
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
104
|
+
return path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AddScenarioOpts {
|
|
108
|
+
capability: string;
|
|
109
|
+
reqId: string;
|
|
110
|
+
scenarioId: string;
|
|
111
|
+
given?: string;
|
|
112
|
+
when: string;
|
|
113
|
+
thenText: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Append `@req:<id> @executable` acceptance scenario (r42). */
|
|
117
|
+
export function addScenario(
|
|
118
|
+
io: WriteIo,
|
|
119
|
+
specsRoot: string,
|
|
120
|
+
entries: readonly SpecEntryLike[],
|
|
121
|
+
opts: AddScenarioOpts,
|
|
122
|
+
): string {
|
|
123
|
+
const path = `${specsRoot}/${opts.capability}.feature`;
|
|
124
|
+
if (!io.exists(path)) throw new AuthoringError(`spec not found: ${path}`);
|
|
125
|
+
if (findReq(entries, opts.reqId) === null) {
|
|
126
|
+
throw new AuthoringError(`req id not found: ${opts.reqId}`);
|
|
127
|
+
}
|
|
128
|
+
const kw = keywordsOf(io.readText(path));
|
|
129
|
+
const givenLine =
|
|
130
|
+
opts.given !== undefined && opts.given !== '' ? ` ${kw.given} ${opts.given}\n` : '';
|
|
131
|
+
const block =
|
|
132
|
+
`\n @req:${opts.reqId} @executable\n ${kw.scenario}: ${opts.scenarioId}\n` +
|
|
133
|
+
`${givenLine} ${kw.when} ${opts.when}\n ${kw.thenText} ${opts.thenText}\n`;
|
|
134
|
+
io.writeText(path, `${io.readText(path).replace(/\n+$/u, '')}${block}`);
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ResolvedReq {
|
|
139
|
+
reqId: string;
|
|
140
|
+
capability: string;
|
|
141
|
+
title: string;
|
|
142
|
+
statement: string;
|
|
143
|
+
harness: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Resolve an rN to capability/statement plus bound harness scenarios (r43). */
|
|
147
|
+
export function resolveReq(entries: readonly SpecEntryLike[], reqId: string): ResolvedReq | null {
|
|
148
|
+
const rule = findReq(entries, reqId);
|
|
149
|
+
if (rule === null) return null;
|
|
150
|
+
const capability =
|
|
151
|
+
rule.entry.doc.header.capability ?? rule.entry.fileName.replace(/\.feature$/u, '');
|
|
152
|
+
const harness: string[] = [];
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
for (const scenario of entry.doc.scenarios) {
|
|
155
|
+
if (scenario.classification === 'executable' && scenario.reqIds.includes(reqId)) {
|
|
156
|
+
harness.push(`${entry.fileName}:${scenario.name}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { reqId, capability, title: rule.scenarioName, statement: rule.statement, harness };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DedupePlanItem {
|
|
164
|
+
reqId: string;
|
|
165
|
+
keepFile: string;
|
|
166
|
+
remapFile: string;
|
|
167
|
+
newReqId: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Plan (and optionally apply) a re-map of globally duplicated rN ids: the
|
|
172
|
+
* first file keeps the id, later files get the next free id (r43).
|
|
173
|
+
*/
|
|
174
|
+
export function planDedupe(
|
|
175
|
+
entries: readonly SpecEntryLike[],
|
|
176
|
+
io: WriteIo,
|
|
177
|
+
specsRoot: string,
|
|
178
|
+
duplicates: readonly { reqId: string; files: string[] }[],
|
|
179
|
+
): DedupePlanItem[] {
|
|
180
|
+
const used = ruleReqIds(entries);
|
|
181
|
+
let next = 1;
|
|
182
|
+
const fresh = (): string => {
|
|
183
|
+
while (used.has(`r${String(next)}`)) next += 1;
|
|
184
|
+
used.add(`r${String(next)}`);
|
|
185
|
+
return `r${String(next)}`;
|
|
186
|
+
};
|
|
187
|
+
const plan: DedupePlanItem[] = [];
|
|
188
|
+
for (const dup of duplicates) {
|
|
189
|
+
const [keep, ...rest] = dup.files;
|
|
190
|
+
for (const remapFile of rest) {
|
|
191
|
+
const newReqId = fresh();
|
|
192
|
+
if (keep !== undefined) {
|
|
193
|
+
plan.push({ reqId: dup.reqId, keepFile: keep, remapFile, newReqId });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const item of plan) {
|
|
198
|
+
// registry duplicates carry bare file names; accept already-rooted paths too
|
|
199
|
+
const path = item.remapFile.startsWith(`${specsRoot}/`)
|
|
200
|
+
? item.remapFile
|
|
201
|
+
: `${specsRoot}/${item.remapFile}`;
|
|
202
|
+
const content = io.readText(path);
|
|
203
|
+
io.writeText(path, content.replaceAll(`@req:${item.reqId}`, `@req:${item.newReqId}`));
|
|
204
|
+
}
|
|
205
|
+
return plan;
|
|
206
|
+
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Embedded template table for single-file binaries.
|
|
3
3
|
*
|
|
4
|
-
* Bun
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
4
|
+
* Bun 1.4.x offers no embedding that survives our Node >= 24 dual-runtime
|
|
5
|
+
* constraint: `with { type: "text" }` works in compiled output (probed on
|
|
6
|
+
* 1.4.2) but Node cannot load that attribute, and `assets`/`?raw`/`?asset`
|
|
7
|
+
* never land in the bundle — so templates are injected through the same
|
|
8
|
+
* build-time `define` mechanism as LLMAN_SDD_VERSION: build-binary.ts
|
|
9
|
+
* collects packages/core/templates into a root-relative path→content map and
|
|
10
|
+
* sets process.env.LLMAN_SDD_EMBEDDED_TEMPLATES to that JSON. Bun inlines
|
|
11
|
+
* define values as raw expressions — the JSON is a valid object literal, so
|
|
12
|
+
* in the compiled binary the expression IS the table object itself, while a
|
|
13
|
+
* JSON string reaches these callers through the same expression in other
|
|
14
|
+
* engines or when force-set via env. Both forms are accepted below; every
|
|
15
|
+
* non-compiled run (source, npm package, Node) leaves the define unset and
|
|
16
|
+
* falls back to the real filesystem via TEMPLATES_ROOT.
|
|
16
17
|
*/
|
|
17
18
|
import type { TemplateIo } from './skills.ts';
|
|
18
19
|
|