@mmnto/totem 1.18.3 → 1.20.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.
@@ -2947,4 +2947,365 @@ describe('compileLesson trace events', () => {
2947
2947
  expect(orchestratorMock).not.toHaveBeenCalled();
2948
2948
  });
2949
2949
  });
2950
+ // ─── ADR-091 Stage 4 integration (mmnto-ai/totem#1682) ──
2951
+ describe('compileLesson Stage 4 integration', () => {
2952
+ function makePipeline2Deps(stage4Result) {
2953
+ const onWarn = vi.fn();
2954
+ const onDim = vi.fn();
2955
+ const onStage4Outcome = vi.fn();
2956
+ const verifyStage4 = stage4Result ? vi.fn().mockResolvedValue(stage4Result) : undefined;
2957
+ const deps = {
2958
+ parseCompilerResponse: vi.fn().mockReturnValue({
2959
+ compilable: true,
2960
+ pattern: 'console\\.log',
2961
+ message: 'No console.log',
2962
+ engine: 'regex',
2963
+ badExample: 'console.log("debug")',
2964
+ goodExample: '// noop\n',
2965
+ }),
2966
+ runOrchestrator: vi.fn().mockResolvedValue('{"compilable": true}'),
2967
+ existingByHash: new Map(),
2968
+ callbacks: { onWarn, onDim, onStage4Outcome },
2969
+ ...(verifyStage4 ? { verifyStage4 } : {}),
2970
+ };
2971
+ return { deps, onWarn, onStage4Outcome, verifyStage4 };
2972
+ }
2973
+ function makePipeline2Lesson() {
2974
+ return {
2975
+ index: 0,
2976
+ heading: 'Pipeline 2 stage 4 lesson',
2977
+ body: 'Body without manual pattern, no Bad/Good snippets — Pipeline 2 path.',
2978
+ hash: 'h-stage4-p2',
2979
+ };
2980
+ }
2981
+ it('does NOT invoke Stage 4 when deps.verifyStage4 is absent (existing behavior preserved)', async () => {
2982
+ const { deps } = makePipeline2Deps();
2983
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
2984
+ expect(result.status).toBe('compiled');
2985
+ if (result.status === 'compiled') {
2986
+ expect(result.rule.status).toBeUndefined();
2987
+ expect(result.rule.confidence).toBeUndefined();
2988
+ }
2989
+ expect(deps.callbacks.onStage4Outcome).not.toHaveBeenCalled();
2990
+ });
2991
+ it("Pipeline 2: 'no-matches' outcome sets status='untested-against-codebase'", async () => {
2992
+ const { deps, onStage4Outcome } = makePipeline2Deps({
2993
+ outcome: 'no-matches',
2994
+ baselineMatches: [],
2995
+ inScopeMatches: [],
2996
+ candidateDebtLines: [],
2997
+ });
2998
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
2999
+ expect(result.status).toBe('compiled');
3000
+ if (result.status === 'compiled') {
3001
+ expect(result.rule.status).toBe('untested-against-codebase');
3002
+ expect(result.rule.confidence).toBeUndefined();
3003
+ expect(result.rule.archivedReason).toBeUndefined();
3004
+ }
3005
+ expect(onStage4Outcome).toHaveBeenCalledTimes(1);
3006
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 4, action: 'verify', outcome: 'no-matches' }));
3007
+ });
3008
+ it("Pipeline 2: 'in-scope-bad-example' outcome sets confidence='high'", async () => {
3009
+ const { deps, onStage4Outcome } = makePipeline2Deps({
3010
+ outcome: 'in-scope-bad-example',
3011
+ baselineMatches: [],
3012
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3013
+ candidateDebtLines: [],
3014
+ });
3015
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
3016
+ expect(result.status).toBe('compiled');
3017
+ if (result.status === 'compiled') {
3018
+ expect(result.rule.confidence).toBe('high');
3019
+ expect(result.rule.status).toBeUndefined();
3020
+ }
3021
+ expect(onStage4Outcome).toHaveBeenCalledTimes(1);
3022
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 4, action: 'verify', outcome: 'in-scope-bad-example' }));
3023
+ });
3024
+ it("Pipeline 2: 'candidate-debt' outcome forces severity='warning' and emits onWarn", async () => {
3025
+ const { deps, onWarn, onStage4Outcome } = makePipeline2Deps({
3026
+ outcome: 'candidate-debt',
3027
+ baselineMatches: [],
3028
+ inScopeMatches: ['packages/cli/src/foo.ts', 'packages/cli/src/bar.ts'],
3029
+ candidateDebtLines: [
3030
+ 'console.log(`${env.X}`)',
3031
+ 'console.log(req.body.id)',
3032
+ "console.log('a')",
3033
+ "console.log('b')",
3034
+ ],
3035
+ });
3036
+ // Make the rule's declared severity 'error' to verify the force-downgrade.
3037
+ deps.parseCompilerResponse.mockReturnValue({
3038
+ compilable: true,
3039
+ pattern: 'console\\.log',
3040
+ message: 'No console.log',
3041
+ engine: 'regex',
3042
+ badExample: 'console.log("debug")',
3043
+ goodExample: '// noop\n',
3044
+ severity: 'error',
3045
+ });
3046
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
3047
+ expect(result.status).toBe('compiled');
3048
+ if (result.status === 'compiled') {
3049
+ expect(result.rule.severity).toBe('warning');
3050
+ }
3051
+ expect(onStage4Outcome).toHaveBeenCalledTimes(1);
3052
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 4, action: 'verify', outcome: 'candidate-debt' }));
3053
+ // Sample of debt sites should be emitted via onWarn.
3054
+ expect(onWarn).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('Stage 4: candidate debt'));
3055
+ expect(onWarn).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('+ 1 more'));
3056
+ });
3057
+ it("Pipeline 2: 'out-of-scope' outcome archives the rule with reasonCode + paths in archivedReason", async () => {
3058
+ const { deps, onWarn, onStage4Outcome } = makePipeline2Deps({
3059
+ outcome: 'out-of-scope',
3060
+ baselineMatches: ['packages/core/src/transport.ts', 'packages/cli/src/foo.test.ts'],
3061
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3062
+ candidateDebtLines: [],
3063
+ });
3064
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
3065
+ expect(result.status).toBe('compiled');
3066
+ if (result.status === 'compiled') {
3067
+ expect(result.rule.status).toBe('archived');
3068
+ expect(result.rule.archivedAt).toBeDefined();
3069
+ expect(result.rule.archivedReason).toContain('Stage 4');
3070
+ expect(result.rule.archivedReason).toContain('stage4-out-of-scope-match');
3071
+ expect(result.rule.archivedReason).toContain('packages/core/src/transport.ts');
3072
+ expect(result.rule.archivedReason).toContain('packages/cli/src/foo.test.ts');
3073
+ }
3074
+ expect(onStage4Outcome).toHaveBeenCalledTimes(1);
3075
+ expect(result.trace).toContainEqual(expect.objectContaining({
3076
+ layer: 4,
3077
+ action: 'verify',
3078
+ outcome: 'out-of-scope',
3079
+ reasonCode: 'stage4-out-of-scope-match',
3080
+ }));
3081
+ expect(onWarn).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('Stage 4: archived'));
3082
+ });
3083
+ it('Pipeline 3 (Bad/Good example-based) wires Stage 4 — outcome mutates the rule (CR mmnto-ai/totem#1757 R2)', async () => {
3084
+ // Stage 4 is wired into both Pipeline 2 and Pipeline 3 success
3085
+ // branches. The other tests in this block pin Pipeline 2; this case
3086
+ // covers Pipeline 3 so a regression in the Pipeline 3 hook can't
3087
+ // bypass verification while the Pipeline 2 cases stay green.
3088
+ const verifyStage4 = vi.fn().mockResolvedValue({
3089
+ outcome: 'in-scope-bad-example',
3090
+ baselineMatches: [],
3091
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3092
+ candidateDebtLines: [],
3093
+ });
3094
+ const onStage4Outcome = vi.fn();
3095
+ const onWarn = vi.fn();
3096
+ const deps = {
3097
+ parseCompilerResponse: vi.fn().mockReturnValue({
3098
+ compilable: true,
3099
+ pattern: 'console\\.log',
3100
+ message: 'No console.log',
3101
+ engine: 'regex',
3102
+ badExample: "console.log('debug')",
3103
+ goodExample: '// noop',
3104
+ }),
3105
+ runOrchestrator: vi.fn().mockResolvedValue('{"compilable": true}'),
3106
+ existingByHash: new Map(),
3107
+ callbacks: { onWarn, onDim: vi.fn(), onStage4Outcome },
3108
+ verifyStage4,
3109
+ };
3110
+ // Pipeline 3 dispatches when `extractBadGoodSnippets` returns
3111
+ // snippets — body needs explicit Bad/Good code blocks AND no
3112
+ // manual `**Pattern:**` (else Pipeline 1 wins).
3113
+ const pipeline3Lesson = {
3114
+ index: 0,
3115
+ heading: 'No console.log in production',
3116
+ body: [
3117
+ '**Bad:**',
3118
+ '',
3119
+ '```ts',
3120
+ "console.log('debug')",
3121
+ '```',
3122
+ '',
3123
+ '**Good:**',
3124
+ '',
3125
+ '```ts',
3126
+ '// noop',
3127
+ '```',
3128
+ ].join('\n'),
3129
+ hash: 'h-stage4-p3',
3130
+ };
3131
+ const result = await compileLesson(pipeline3Lesson, 'system prompt', deps);
3132
+ expect(result.status).toBe('compiled');
3133
+ if (result.status === 'compiled') {
3134
+ expect(result.rule.confidence).toBe('high');
3135
+ }
3136
+ expect(verifyStage4).toHaveBeenCalledTimes(1);
3137
+ expect(onStage4Outcome).toHaveBeenCalledTimes(1);
3138
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 4, action: 'verify', outcome: 'in-scope-bad-example' }));
3139
+ });
3140
+ it('Pipeline 1 (manual rule) bypasses Stage 4 — verifyStage4 not invoked', async () => {
3141
+ // Pipeline 1 manual rules are human-authored and self-evidencing per the
3142
+ // Pipeline 1 / unverified semantics; Stage 4 is a safety net for LLM-
3143
+ // generated patterns. The integration site only invokes Stage 4 from
3144
+ // Pipeline 2 / Pipeline 3 success branches.
3145
+ const verifyStage4 = vi.fn().mockResolvedValue({
3146
+ outcome: 'no-matches',
3147
+ baselineMatches: [],
3148
+ inScopeMatches: [],
3149
+ candidateDebtLines: [],
3150
+ });
3151
+ const deps = {
3152
+ parseCompilerResponse: vi.fn(),
3153
+ runOrchestrator: vi.fn(),
3154
+ existingByHash: new Map(),
3155
+ callbacks: { onWarn: vi.fn(), onDim: vi.fn() },
3156
+ verifyStage4,
3157
+ };
3158
+ const result = await compileLesson(manualLesson, 'system prompt', deps);
3159
+ expect(result.status).toBe('compiled');
3160
+ expect(verifyStage4).not.toHaveBeenCalled();
3161
+ });
3162
+ it('preserves the layer-3 MATCH trace event alongside the new layer-4 verify event', async () => {
3163
+ // Stage 4 appends to the trace; it does not replace existing events.
3164
+ // The CLI --verbose renderer relies on the full sequence.
3165
+ const { deps } = makePipeline2Deps({
3166
+ outcome: 'no-matches',
3167
+ baselineMatches: [],
3168
+ inScopeMatches: [],
3169
+ candidateDebtLines: [],
3170
+ });
3171
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
3172
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 3, action: 'verify', outcome: 'MATCH' }));
3173
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 3, action: 'result', outcome: 'compiled' }));
3174
+ expect(result.trace).toContainEqual(expect.objectContaining({ layer: 4, action: 'verify', outcome: 'no-matches' }));
3175
+ });
3176
+ it("'in-scope-bad-example' promotes a carry-forward 'untested-against-codebase' rule to 'active' — CR mmnto-ai/totem#1757 R2", async () => {
3177
+ // F6 filters `'untested-against-codebase'` out of the lint path, so
3178
+ // a recompile that produces positive Stage 4 evidence MUST clear the
3179
+ // stale status or the rule stays inert despite high-confidence
3180
+ // matches. Tested for both in-scope-bad-example (this case) and
3181
+ // candidate-debt (next case).
3182
+ const lesson = makePipeline2Lesson();
3183
+ const { deps } = makePipeline2Deps({
3184
+ outcome: 'in-scope-bad-example',
3185
+ baselineMatches: [],
3186
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3187
+ candidateDebtLines: [],
3188
+ });
3189
+ deps.existingByHash = new Map([
3190
+ [
3191
+ lesson.hash,
3192
+ {
3193
+ lessonHash: lesson.hash,
3194
+ message: 'previously-untested',
3195
+ pattern: 'console\\.log',
3196
+ createdAt: '2026-01-01T00:00:00.000Z',
3197
+ status: 'untested-against-codebase',
3198
+ },
3199
+ ],
3200
+ ]);
3201
+ const result = await compileLesson(lesson, 'system prompt', deps);
3202
+ expect(result.status).toBe('compiled');
3203
+ if (result.status === 'compiled') {
3204
+ expect(result.rule.status).toBe('active');
3205
+ expect(result.rule.confidence).toBe('high');
3206
+ }
3207
+ });
3208
+ it("'candidate-debt' promotes a carry-forward 'untested-against-codebase' rule to 'active' — CR mmnto-ai/totem#1757 R2", async () => {
3209
+ const lesson = makePipeline2Lesson();
3210
+ const { deps } = makePipeline2Deps({
3211
+ outcome: 'candidate-debt',
3212
+ baselineMatches: [],
3213
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3214
+ candidateDebtLines: ['console.log(req.body.id)'],
3215
+ });
3216
+ deps.existingByHash = new Map([
3217
+ [
3218
+ lesson.hash,
3219
+ {
3220
+ lessonHash: lesson.hash,
3221
+ message: 'previously-untested',
3222
+ pattern: 'console\\.log',
3223
+ createdAt: '2026-01-01T00:00:00.000Z',
3224
+ status: 'untested-against-codebase',
3225
+ },
3226
+ ],
3227
+ ]);
3228
+ const result = await compileLesson(lesson, 'system prompt', deps);
3229
+ expect(result.status).toBe('compiled');
3230
+ if (result.status === 'compiled') {
3231
+ expect(result.rule.status).toBe('active');
3232
+ expect(result.rule.severity).toBe('warning');
3233
+ }
3234
+ });
3235
+ it("'no-matches' preserves a previously archived rule's status (carry-forward) — CR mmnto-ai/totem#1757 R1", async () => {
3236
+ // `preserveLifecycleFields` carries `status: 'archived'` (and its
3237
+ // `archivedReason`/`archivedAt`) forward on `--force` recompile.
3238
+ // Setting `status = 'untested-against-codebase'` unconditionally on
3239
+ // a `'no-matches'` outcome would silently un-archive a rule that
3240
+ // postmerge curation explicitly silenced.
3241
+ const lesson = makePipeline2Lesson();
3242
+ const { deps } = makePipeline2Deps({
3243
+ outcome: 'no-matches',
3244
+ baselineMatches: [],
3245
+ inScopeMatches: [],
3246
+ candidateDebtLines: [],
3247
+ });
3248
+ deps.existingByHash = new Map([
3249
+ [
3250
+ lesson.hash,
3251
+ {
3252
+ lessonHash: lesson.hash,
3253
+ message: 'previously-archived',
3254
+ pattern: 'console\\.log',
3255
+ createdAt: '2026-01-01T00:00:00.000Z',
3256
+ status: 'archived',
3257
+ archivedReason: 'manual archive (postmerge curation)',
3258
+ archivedAt: '2026-02-01T00:00:00.000Z',
3259
+ },
3260
+ ],
3261
+ ]);
3262
+ const result = await compileLesson(lesson, 'system prompt', deps);
3263
+ expect(result.status).toBe('compiled');
3264
+ if (result.status === 'compiled') {
3265
+ expect(result.rule.status).toBe('archived');
3266
+ expect(result.rule.archivedReason).toBe('manual archive (postmerge curation)');
3267
+ expect(result.rule.archivedAt).toBe('2026-02-01T00:00:00.000Z');
3268
+ }
3269
+ });
3270
+ it("'candidate-debt' sanitizes CSI bytes in debtLines before onWarn — CR mmnto-ai/totem#1757 R1", async () => {
3271
+ // Repository code can carry CSI / control bytes from a tampered
3272
+ // file. `onWarn` lands in terminal output; raw text would let a
3273
+ // hostile pattern spoof cursor moves or color resets. Mirrors the
3274
+ // #1743 R4-R7 sanitization wave on the agent-rendered surface.
3275
+ const { deps, onWarn } = makePipeline2Deps({
3276
+ outcome: 'candidate-debt',
3277
+ baselineMatches: [],
3278
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3279
+ candidateDebtLines: ['console.log(\x1b[31m"red"\x1b[0m)', 'console.log("\x07bell")'],
3280
+ });
3281
+ await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
3282
+ const warnCall = onWarn.mock.calls.find((call) => typeof call[1] === 'string' && call[1].includes('candidate debt'));
3283
+ expect(warnCall).toBeDefined();
3284
+ const message = warnCall[1];
3285
+ // No raw ESC, no raw BEL after sanitization.
3286
+ expect(message).not.toMatch(/\x1b/);
3287
+ expect(message).not.toMatch(/\x07/);
3288
+ // Visible code shape preserved (CSI sequence stripped, payload kept).
3289
+ expect(message).toContain('"red"');
3290
+ });
3291
+ it("'out-of-scope' sanitizes CSI bytes in baselineMatches before archivedReason — CR mmnto-ai/totem#1757 R1", async () => {
3292
+ // Path text persists into compiled-rules.json (`archivedReason`)
3293
+ // and surfaces in `onWarn`. A hostile filename with CSI bytes would
3294
+ // re-emerge whenever the manifest is tailed in a terminal.
3295
+ const { deps } = makePipeline2Deps({
3296
+ outcome: 'out-of-scope',
3297
+ baselineMatches: ['packages/cli/src/\x1b[31mhostile\x1b[0m.test.ts'],
3298
+ inScopeMatches: ['packages/cli/src/foo.ts'],
3299
+ candidateDebtLines: [],
3300
+ });
3301
+ const result = await compileLesson(makePipeline2Lesson(), 'system prompt', deps);
3302
+ expect(result.status).toBe('compiled');
3303
+ if (result.status === 'compiled') {
3304
+ expect(result.rule.archivedReason).toBeDefined();
3305
+ expect(result.rule.archivedReason).not.toMatch(/\x1b/);
3306
+ // Path payload still present, just stripped of escape bytes.
3307
+ expect(result.rule.archivedReason).toContain('hostile');
3308
+ }
3309
+ });
3310
+ });
2950
3311
  //# sourceMappingURL=compile-lesson.test.js.map